Docs

Performance report

What NSH apps cost, measured. How NSH keeps them fast and how to measure your own app is in Performance.

#Baselines

From NSH’s own baselines (Apple silicon unless noted):

ScenarioNumbers
Counter app, headless (debug)First frame ~0.5–0.65 ms (58 ops / 49 mount items); one tap ~0.15 ms
Counter app, Mac Catalyst (release)First frame ~1.05–1.15 ms (about 0.7 ms of it lays out 10 texts with CoreText for the first time); one tap ~0.12 ms
Gallery photo list, headless (debug)First frame 653 ops / 224 nodes, ~2.7 ms
Scrolling the photo list, Catalyst (release)Rebinding one row while scrolling slowly ~0.4 ms; a whole screen of ~32 rows on a fast fling 0.9–1.6 ms
Scrolling the photo list, Android emulator (arm64, release)Rebinding one row ~0.3–0.4 ms (JS ~60 µs)
Engine startup (release)~75–155 µs; the global APIs (fetch, URL, …) cost nothing until first used, then ~90 µs
Hot reload~120 ms from saving a file to the new version running; applying the update 0.2–0.5 ms

#Linux (GTK 4)

Measured on a Linux desktop (x86_64, 24 threads, NVIDIA GPU), release builds, the gallery in a 1896 × 1030 window on a 120 Hz display, every reading taken automatically by examples/gallery/tools/perf.py (nsh test --target linux --fps, one line a second; CPU is the whole process, in percent of one core). Frame p95 is the main thread’s time per drawn frame at the 95th percentile: under 8.3 ms, a frame fits a 120 Hz refresh.

#Animated demos

DemoFPSFrame p95CPU
Stocks: 22 live sparklines, an intraday chart, order book, tape117–1205–6 ms25–29% (1200 × 800), 28–32% (1896 × 1030)
Stocks, the window filling a 4K 144 Hz display at 1.6×143–1454.1–4.4 ms26–29%
Canvas: a gauge, a clock, a dot matrix1202.6–4.4 ms5–10%
Animations: three native loops, springs, sequences1202.0 ms12–13%

#Every page

Every page of the gallery, as it opens and while the test scrolls it, in a 1200 × 800 window on a 144 Hz display (the same machine). NSH draws a frame only when something on screen changes, so FPS here is the rate while something moves: one frame per median gap between drawn frames (perf.py’s moving); at the display’s 144, every frame of an animation or a scroll comes one refresh after the last. Over one refresh is the share of frames that took longer than a refresh (6.9 ms). Images’ GIF and WebP play at their own frame rates (about 50 FPS here), as does video. Physics, Overlays and Menus are short: in 1200 × 800 a scroll reaches their end within a few frames, so they are measured in a 900 × 500 window, where they have room to scroll.

PageFrames come fromFPSFrame p95Over one refreshCPU
StocksLive data, native loops1444.1 ms0%26%
Stocks, scrollingLive data, scroll1444.1 ms0%25%
Photos, scrollingScroll1442.8 ms2%9%
Lists, scrollingScroll1431.7 ms0%7%
Scrolling, scrollingScroll1441.6 ms1%4%
Navigation, scrollingScroll1431.7 ms0%2%
Form, scrollingScroll1441.3 ms0%4%
Resizable, scrollingScroll1430.6 ms0%1%
Dock, scrollingScroll1420.5 ms1%1%
GesturesSprings, a pulsing ring1441.6 ms0%1–5%
Physics, scrollingScroll (a 900 × 500 window)1441.6 ms1%1%
AnimationsNative loops1442.6 ms0–1%5%
Canvas, scrollingScroll, native loops1444.2 ms0%1–4%
Styles, scrollingScroll1442.3 ms2%3%
Tailwindanimate-* utilities1441.7 ms0%3–8%
Text, scrollingScroll1441.9 ms0%4%
Text View, scrollingScroll1411.8 ms2%7%
Images, scrollingScroll, GIF and WebP frames1432.9 ms2%8%
Components, scrollingScroll1441.4 ms0%3%
Overlays, scrollingScroll (a 900 × 500 window)1441.7 ms0%2%
Menus, scrollingScroll (a 900 × 500 window)1441.9 ms0%2%
ControlsTransitions1440.9 ms0%1–4%
Web and videoVideo frames, the page135–1421.0 ms0%7–8%

#Memory

At rest, 3 s after start (release runners, a 480 × 640 window). Private is the memory the process alone owns (USS); resident adds the shared libraries’ pages (GTK, the GPU driver), which every GTK app on the machine shares:

AppRendererPrivate (USS)Resident (RSS)
Rust app (nsh init --template rust)GL (ngl)~49 MB~142 MB
Rust appCairo (GSK_RENDERER=cairo)~28 MB~95 MB
TSX counter (examples/counter)GL~79 MB~166 MB
GTK 4 window with one label, for referenceVulkan / Cairo~62 / ~40 MB~132 / ~109 MB

Revisiting a page adds no memory; the first visit to a page grows the process by what it caches on purpose (decoded images, bounded at 128 MB) and the libraries it loads on first use (WebKitGTK for web views).

#App size

A hello world app made by nsh init with each template, and two apps people use, as nsh package --target linux builds them for x86_64. The runner is the app’s one executable; the archive is the .tar.gz it ships as. The hello worlds are built with link-time optimization and stripped (CARGO_PROFILE_RELEASE_LTO=fat, CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1, CARGO_PROFILE_RELEASE_STRIP=true):

AppWritten inRunnerArchive
Hello world (--template rust)Rust16.5 MB7.5 MB
Hello world (--template ts)TSX, on QuickJS25.7 MB11.6 MB
Hello world (--template mixed)TSX and a Rust crate25.8 MB11.7 MB
sysmon 0.1.3Rust20.9 MB stripped9.7 MB
Omasend 0.2.0Rust22.3 MB9.8 MB

The TSX runners hold the QuickJS runtime, the native modules and the renderer; an app’s own code is its bundle beside the runner, under 1 KB for a hello world.

What makes these numbers:

  • The renderer: under NVIDIA’s driver NSH draws with GSK’s GL renderer, whose frames cost a fraction of the Vulkan renderer’s there (the Animations demo: ~1 ms of drawing per frame instead of ~9.5 ms). GSK_RENDERER still chooses.
  • Canvas rasters: each canvas is rasterized once into 256 px tiles kept on the GPU. An animated value redraws only the area the operations reading it cover, over the kept pixels of the drawing’s static part; a canvas out of sight waits until it scrolls into view.
  • Paths set aside once: fillRect and fillText after a long path leave it in place until it is used again (a 400-point line then 240 bars: 0.63 ms).

#macOS (AppKit)

Measured on a MacBook Pro (Apple M3, 24 GB) on its built-in 120 Hz ProMotion display, release builds, the gallery in a 1200 × 800 window, every reading taken automatically by examples/gallery/tools/perf.py (nsh test --target macos --fps, one line a second; CPU is the whole process, in percent of one core, GPU its share of the GPU). The phases are the main thread’s per frame: tick (the display link’s native animations) and canvas (replaying drawings).

#Animated demos

DemoFPSFrame p95CPUGPU
Stocks: 22 live sparklines, an intraday chart, order book, tape1202.7–3.7 ms33–36%18–22%
Canvas: a gauge, a clock, a dot matrix119–1200.6–0.7 ms4–7%0%
Animations: three native loops, springs, sequences119–1200.3–1.6 ms5–8%0%

On Stocks a frame’s main-thread time is ~1.6 ms of ticks, of which ~0.45 ms replays the canvases’ animated parts; Core Animation draws the rest of each canvas on its own threads.

#Every page

Every page of the gallery, as it opens and while the test scrolls it, in the same 1200 × 800 window. As on Linux, FPS is the rate while something moves (one frame per median gap between drawn frames); at the display’s 120, every frame of an animation or a scroll comes one refresh after the last. Over one refresh is the share of frames that took longer than a refresh (8.3 ms). A page at rest draws no frame and uses 2–5% CPU. Images’ GIF and WebP play in Core Animation and video in AVFoundation, at their own frame rates.

PageFrames come fromFPSFrame p95Over one refreshCPU
StocksLive data, native loops1203.2 ms0%35%
Stocks, scrollingLive data, scroll1203.5 ms0%36%
AI Chat, scrollingScroll1202.4 ms1%5%
Photos, scrollingScroll1201.4 ms0%10%
Lists, scrollingScroll1201.6 ms0%8%
Scrolling, scrollingScroll1202.7 ms0%6%
Navigation, scrollingScroll1200.2 ms0%4%
Form, scrollingScroll1200.6 ms0%5%
Resizable, scrollingScroll1200.3 ms0%4%
Dock, scrollingScroll1200.3 ms1%5%
GesturesSprings, a pulsing ring1200.4 ms0%4%
Physics, scrollingScroll1200.3 ms0%4%
AnimationsNative loops1200.3 ms0%5%
Canvas, scrollingScroll, native loops1201.2 ms0%7%
Styles, scrollingScroll, a swaying transform1201.0 ms0%6%
Tailwindanimate-* utilities1200.5 ms0%4%
Text, scrollingScroll1200.3 ms0%5%
Text View, scrollingScroll1200.7 ms0%5%
Images, scrollingScroll1200.4 ms0%5%
Components, scrollingScroll1200.3 ms0%5%
Overlays, scrollingScroll1200.3 ms0%4%
Menus, scrollingScroll1200.3 ms0%5%
ControlsTransitions1200.3 ms0%4%
Web and video, scrollingScroll1200.2 ms0%6%
SystemrequestAnimationFrame, timers1200.2 ms0%3%
Standard APIs, scrollingScroll1200.3 ms0%5%
Storage, scrollingScroll1200.3 ms0%5%

#Memory

At rest, 3 s after start (release runners). Footprint is the memory macOS charges the process (what Activity Monitor shows); resident adds the pages of the system’s shared libraries:

AppWindowFootprintResident (RSS)
Rust app (nsh init --template rust)1200 × 800~19 MB~86 MB
TSX counter (examples/counter)1200 × 800~20 MB~89 MB
AppKit window with one label, for reference480 × 640~17 MB~80 MB

Through the gallery’s tour the process holds 84–230 MB up to the Canvas page and ~350–360 MB from the Text View page on, with what it caches on purpose (decoded images, bounded at 128 MB; the Text View page’s documents) and the frameworks it loads on first use (WebKit for web views); the Canvas page’s rasters, up to ~310 MB with all three canvases on screen, go when the page closes.

#App size

The hello world apps made by nsh init with each template, as nsh package --target macos builds them for Apple silicon (arm64), with link-time optimization and stripped as on Linux, and the two apps above as they ship for Apple silicon. The executable is the runner inside the .app; the zip is the .app compressed (ditto -c -k):

AppWritten inExecutableZipped .app
Hello world (--template rust)Rust12.4 MB6.0 MB
Hello world (--template ts)TSX, on QuickJS19.8 MB9.5 MB
Hello world (--template mixed)TSX and a Rust crate19.8 MB9.5 MB
sysmon 0.1.3Rust21.6 MB8.4 MB (its release’s .tar.gz)
Omasend 0.2.0Rust18.4 MB9.7 MB (its release’s .tar.gz)

What makes these numbers:

  • Frames only for changes: the display link ticks while a native animation runs and stops with it; an animation stops with its page (its view’s deletion ends its keyframes, a loop stops in its component’s cleanup).
  • Canvases drawn where they cost least: a canvas that paints often (an animation, live data) draws on Core Animation’s threads (drawsAsynchronously), which keeps the main thread free; one redrawn now and then draws at once, without the graphics memory asynchronous drawing holds. An animated value redraws only the area the operations reading it cover.
  • Static canvas parts kept: once a drawing stays unchanged, a long part before its first animated operation is kept as a layer; each frame draws that layer and replays only the animated rest, at once on the main thread (the Canvas page’s gauge, clock and dot matrix: no GPU work and half the memory).
  • Content drawn when it changes: texts and canvases draw into layers of their own, again when their content or size changes, not when they move or scroll.

#Windows (Win32, Direct2D)

Measured on a Windows 11 desktop (Intel Core i7-13700KF, 32 GB, NVIDIA GeForce RTX 3060 Ti) on a 4K 144 Hz display at 150%, release builds, the gallery in a 1200 × 800 window (1800 × 1200 pixels), every reading taken automatically by examples/gallery/tools/perf.py (nsh test --target windows --fps, one line a second; CPU is the whole process, in percent of one core). The phases are the main thread’s per frame: paint (Direct2D drawing the window) and, within it, canvas (recording and replaying drawings).

#Animated demos

DemoFPSFrame p95Paint / canvas per frameCPU
Stocks: 22 live sparklines, an intraday chart, order book, tape1443.7–4.3 ms1.4 / 0.13 ms36–43%
Canvas: a gauge, a clock, a dot matrix1441.0–2.0 ms0.8–1.4 / 0.05 ms21–35%
Animations: three native loops, springs, sequences1441.0–3.6 ms0.9–1.9 ms20–41%

Stocks commits 16–17 times a second, each in up to 2.1 ms of JS and 1.7 ms of the core on the engine thread (p95).

#Every page

Every page of the gallery, as it opens and while the test scrolls it, in the same 1200 × 800 window. As on Linux and macOS, FPS is the rate while something moves (one frame per median gap between drawn frames); at the display’s 144, every frame of an animation or a scroll comes one refresh after the last. Over one refresh is the share of frames that took longer than a refresh (6.9 ms). A page at rest draws no frame and uses 0–2% CPU. Images’ GIF and WebP play at their own frame rates (about 40 FPS here). Physics, Overlays, Menus and Storage are measured in a 900 × 500 window, where they have room to scroll.

PageFrames come fromFPSFrame p95Over one refreshCPU
StocksLive data, native loops1443.9 ms0%36%
Stocks, scrollingLive data, scroll1444.3 ms0%43%
Photos, scrollingScroll1432.1 ms0%9%
Lists, scrollingScroll1442.0 ms1%19%
Scrolling, scrollingScroll1432.1 ms0%7%
Navigation, scrollingScroll1440.8 ms0%9%
Form, scrollingScroll1440.8 ms0%12%
Resizable, scrollingScroll1430.4 ms0%4%
Dock, scrollingScroll1440.5 ms1%5%
GesturesSprings, a pulsing ring1441.8 ms0%36%
Physics, scrollingScroll (a 900 × 500 window)1440.8 ms0%4%
AnimationsNative loops1441.0 ms0%28%
Canvas, scrollingScroll, native loops1442.0 ms0%35%
Styles, scrollingScroll, a swaying transform1442.1 ms0%39%
Tailwindanimate-* utilities1440.6 ms0%19%
Text, scrollingScroll1441.6 ms0%8%
Text View, scrollingScroll1441.1 ms0%10%
Images, scrollingScroll, GIF and WebP frames1434.8 ms2%23%
Components, scrollingScroll1441.0 ms1%11%
Overlays, scrollingScroll (a 900 × 500 window)1440.6 ms0%1%
Menus, scrollingScroll (a 900 × 500 window)1440.6 ms0%2%
ControlsTransitions117–1441.5 ms0%22%
Web and video, scrollingScroll1441.6 ms0%15%
SystemrequestAnimationFrame, timers144< 0.1 ms0%1%
Storage, scrollingScroll (a 900 × 500 window)1440.5 ms0%1%

#Memory

At rest, 3 s after start (release runners, a 1200 × 800 window). Private working set is the memory the process alone uses (Task Manager’s Memory column); private bytes what it has committed; working set adds the pages of the system’s libraries it shares with other processes (Direct3D, Direct2D, DirectWrite, the GPU driver):

AppPrivate working setPrivate bytesWorking set
Rust app (nsh init --template rust)~14 MB~60 MB~53–71 MB
TSX counter (examples/counter)~15 MB~61 MB~56–71 MB

Through the gallery’s tour the working set is ~108–115 MB on Stocks, ~230 MB from the Canvas page, ~320–340 MB from the Text View page and ~390–405 MB once Web and video has opened WebView2 (whose pages run in WebView2’s own processes), with what it caches on purpose (decoded images, bounded at 128 MB; the Text View page’s documents).

#App size

The hello world apps made by nsh init with each template, as nsh package --target windows builds them for x86_64 (the Rust runners with link-time optimization and stripped, as on Linux). The executable is the app’s <Name>.exe; the zip is the .zip written beside its directory:

AppWritten inExecutableZip
Hello world (--template rust)Rust15.0 MB6.7 MB
Hello world (--template ts)TSX, on QuickJS28.6 MB12.4 MB
Hello world (--template mixed)TSX and a Rust crate26.3 MB11.5 MB

What makes these numbers:

  • Frames only for changes, drawn only where they changed: the compositor-paced ticker runs while a native animation, a transition or a frame callback runs. A frame draws the areas of the views that changed (where they were and where they are), over the pixels the window keeps, and presents only those to the compositor (Stocks: ~34 views a frame).
  • Canvases record their animated part: a drawing is recorded as Direct2D command lists, vector and sharp at any scale. When an animated value changes, only the operations from the first one that reads an animated value on are recorded again; what comes before is kept (Stocks: ~26 ms of recording a second for 22 pulsing sparklines and the chart).
  • Only what shows is drawn: the children of a scroll view or a clipping view that lie out of sight are left out of the frame, and views with no content of their own draw no more than their background and border.

#iOS (UIKit)

Measured in the iOS simulator (an iPhone 18 Pro Max, on the MacBook Pro above), release builds, with the same tour (perf.py --target ios: nsh test --target ios --fps, a finger’s touches as UIKit gets them). The simulator presents at 60 Hz; on an iPhone with a ProMotion screen NSH’s display links run at the screen’s 120 Hz (apps that nsh package builds and the development host ask for it with CADisableMinimumFrameDurationOnPhone). The phases are tick (native animations), drain (applying the engine’s batches) and scroll (what follows a scroll view: lists, sticky cells, bound values).

PageFrames come fromFPSFrame p95CPU
Home, scrollingScroll600.1 ms6%
StocksLive data, native loops600.3 ms22%
Stocks, scrollingLive data, scroll600.3 ms25%
Photos, scrollingScroll601.2 ms6%
Lists, scrollingScroll601.1 ms8%
Scrolling, scrollingScroll600.1 ms7%
Navigation, scrollingScroll600.2 ms7%
Form, scrollingScroll600.3 ms7%
GesturesSprings, a pulsing ring600.3 ms3%
AnimationsNative loops600.7 ms6%
CanvasNative loops600.4 ms16%
StylesA swaying transform600.4 ms4%
Tailwind, scrollingScroll600.2 ms7%
Text, scrollingScroll600.1 ms7%
Text View, scrollingScroll600.1 ms6%
Images, scrollingScroll600.1 ms5%
Components, scrollingScroll600.2 ms6%
Overlays, scrollingScroll600.1 ms6%
Menus, scrollingScroll600.2 ms7%
Controls, scrollingScroll, transitions600.2 ms5%
Web and video, scrollingScroll600.1 ms8%
SystemrequestAnimationFrame, timers600.3 ms4%
Standard APIs, scrollingScroll600.1 ms7%
Storage, scrollingScroll600.1 ms8%

FPS is the rate while something moves, as above: at the simulator’s 60, every frame comes one refresh after the last. Every page with nothing moving draws no frame and uses 1–2% CPU at rest. The process holds 40 MB on the gallery’s home page and ~180 MB after the whole tour (images cached on purpose, the Text View page’s documents).

On an iPhone 17 Pro (ProMotion, 120 Hz), with the development host and one demo on the screen, the animated pages draw every refresh:

PageFrames come fromFPSFrame p95Over one refreshCPUMemory
StocksLive data, native loops1200.2–0.3 ms0%46–70%41–43 MB
CanvasNative loops1200.1 ms0%25–36%37–38 MB
AnimationsNative loops1200.2 ms0%3–11%37 MB

Stocks commits 20 times a second (a tick every 50 ms), each in 1.5 ms of JS and 1.4 ms of the core on the engine thread (p95 2.2 and 2.0 ms), which is busy 4–6% of the time.

#Measuring an app

To measure an app the same way, run its tests or steps with --fps and -v on --target linux, macos, windows or ios:

cargo build --release -p nsh-cli
python3 examples/gallery/tools/perf.py --target macos --only Stocks,Canvas   # the gallery, page by page
echo 'await app.wait(5000)' | nsh -v test App.tsx --target linux --fps        # any app