Skip to content

Memory: where VS Code's memory goes

Generated from docs/perf/memory-vscode.md when the site was built at 7aa23e2.

VS Code for the web 1.145.2 (app.html in the standalone build: the workbench with its builtin extensions, no test driver) idles at the Welcome page in about 370 MB headless, 600 MB in niso-iced and 640 MB in the SwiftUI host, against about 550 MB for Chrome across its nine processes. Measured on beako (Mac mini M4 Pro, macOS 27) on 2026-11-09, release builds of main 725ab09 plus the instrumentation on this branch.

Most of it is not the DOM. The page has 754 nodes, and blitz, taffy, parley and fontique hold under 6 MB together. QuickJS holds 165 to 190 MB for the window alone, malloc keeps another 60 to 150 MB of freed memory dirty after the load, and the iced host’s graphics are 50 to 225 MB.

Idle at the Welcome page unless noted; “headless” is niso-run, “iced” is niso-iced at 1280x800. MB is 2^20 bytes throughout, as footprint, vmmap and malloc_history print it. Savings are measured where the table says so, and estimated from the measured parts otherwise.

# Cause MB Fix Saving Size
1 QuickJS’s main-thread heap beyond what V8 needs: 165 MB counted after a full GC against Chrome’s 52 MB V8 heap (rows 2 and 5 are parts of it; row 4 adds to it under load) 165-190 rows 2, 4, 5
2 QuickJS keeps a copy of every function’s source, and a nested function’s text is inside each enclosing copy 39.5 Drop function source behind a runtime flag (Function.prototype.toString then gives a placeholder); or keep one source per script and offsets in functions measured: -43 MB counted heap, but code that rebuilds a function from its text breaks (VS Code’s perfBaseline worker; see the follow-up); one shared copy instead: about -16 MB flag: ~20 lines in QuickJS; shared source: ~200
3 Freed load-time buffers left dirty: three copies of the 20.7 MB workbench bundle (niso-run’s loader, Net::load_local, Dom::out_bytes) plus 11 MB of parser buffers, held as “Malloc Large (empty)” 62-73 headless, ~21 iced Hand the loaded bytes to QuickJS without copying (one owned, NUL-terminated buffer from the loader to JS_Eval), or map files instead of reading them 40-60 ~100 lines in niso-core’s net.rs, ffi.rs and loading.rs (owned by the architecture agents)
4 QuickJS’s GC threshold: a cycle collection runs only when the heap reaches 1.5x what survived the last one, so under editing the window’s heap swings between 190 and 380 MB and malloc keeps the high-water mark 60-100 under load Collect earlier: threshold at 1.125x, or a GC when the event loop goes idle measured with QuickJS’s own factor patched to 1.125 (a measurement build of 725ab09, not kept): -82 MB footprint after 17 min of the driver (425 vs 507 MB); driver cycles about 13% slower 10 lines in qe_quickjs.c
5 Never-called functions compiled eagerly: 101k of 119k functions (85%) were never called, and their headers, bytecode, variable tables and constants take 32 MB (and 30 MB of the source in row 2) 32 Lazy compilation (parse to a skeleton, compile on first call); a smaller step: shrink JSFunctionBytecode (128 B x 119k = 15 MB) 25-32 lazy: weeks, a QuickJS parser change; header: medium
6 Malloc fragmentation after the load peak: 78 MB free in the small zone (27%), freed after the load’s peak 78 headless Lower the peak (rows 2 and 3 do most of it); the QuickJS arena is not the cause (measured: system malloc for every block is 12 MB worse) follows rows 2-3
7 iced: GPU objects of 4 MB the app’s Metal device did not create: 4 to 46 of them, 24 in the SwiftUI host 0-185 Unattributed; see “Hosts”. First experiment: draw only when the render stream changes instead of every vsync unknown small
8 Each worker: its own runtime and glue (4.3 MB before its script), its script’s heap, arena and malloc overhead, its share of wasm and a font collection ~21 per worker (64 for the three live at idle in iced) Precompile the glue to bytecode without source; skip fontique’s system scan in worker Doms 1-2 per worker; the rest is the worker’s own script and state small each
9 iced: wgpu’s own resources: MSAA target 16.6 + 4.3 MB, image atlas 16.9 MB, per-layer quad buffers 22 x 0.95 MB, per-layer image buffers 36 x 0.26 MB, drawables 8.3 MB ~85 Turn MSAA off (.antialiasing(false); iced turns it on by default) if the borders and paths iced’s triangle pipeline draws still look right; fewer iced layers, or smaller initial per-layer buffers 21 (MSAA), 15-25 (layers) MSAA: one line; layers: medium
10 Stylo for the 1.8 MB workbench stylesheet: declarations 6.8, selectors 5.3, invalidation map 3.7, rule parser 2.6, custom properties 2.1 MB 26 None cheap (upstream stylo); per-element computed values are under 1 MB
11 Wrappers kept alive by listener cycles (docs/listener-cycles.md): IndexedDB requests and transactions, MutationObservers, terminated Workers grows ~1 MB per 10 driver cycles The redesign in docs/listener-cycles.md; until then, drop the core’s listeners of an IDBRequest after its final event, of an IDBTransaction after complete/abort, of a disconnected MutationObserver with no records, and of a terminated Worker the growth in row 11 medium
12 iced: Fonts::register copied every font the render stream registers (data.to_vec()), though the core holds the same bytes mapped. Fixed in the follow-up: the host keeps the core’s Blob 13.4 Keep a reference to the core’s font data instead of a copy 13 small in the host, if the render stream can hand out a lasting reference
13 SwiftUI host: SwiftUI, AttributeGraph and Swift collections for one view per render node 58 malloc + 37 untagged Flatten static subtrees into one drawing view large

Rows 2, 4 and 5 overlap row 1. QuickJS’s heap is the largest single cost, but changing the engine alone does not shrink the process: niso-node on V8 peaks at 518-525 MB against niso-run’s 387 MB (measured separately on the same machine), because V8’s heap, its code space and Node’s own 40-50 MB replace QuickJS’s.

NISO_QJS_MEMORY=SECONDS (this branch, bindings/js/quickjs/qe_quickjs.c) makes every engine, the window’s and each worker’s, run a full GC and print JS_ComputeMemoryUsage as one line of key=value pairs at most every SECONDS (at the end of a microtask checkpoint), after any other full GC (so niso-run’s --time prints one at the end), and when the engine is freed. Each line is labelled with the global’s name (VS Code names its workers) or its location.

Terminal window
NISO_QJS_MEMORY=60 niso-run --page app.html --root . --url http://localhost/app.html --max-ms 30000 --time
niso-qjs-memory http://localhost/app.html gc t=... malloc_size=167283496 ... js_func_size=65482279 ...

After the workbench is up and a full GC (headless, three runs within 2 MB):

Part MB Count
Counted by QuickJS (malloc_size) 167.3 1.44 M blocks
Functions (js_func_size): headers 15.2, variable tables 6.9, closure variable tables 1.9, constant pools 2.0, source 39.5 65.5 119,122
Bytecode 13.2
pc2line (line and column tables) 2.4 110,483
Objects 22.3 310,038
Properties 16.4 915,861
Shapes 3.7 18,793
Atoms 13.1 164,099
Strings 3.3 39,823
Arrays (fast) 32,056 with 201,332 elements
Not itemized (8 B allocator header per block, Maps, closures’ var refs, async frames) 27.4

The bundle the functions come from is 20.7 MB (21.7 million bytes, workbench.web.main.internal.js) plus 1.6 MB of NLS messages. Chrome’s V8 heap for the same page and state is 52 MB used, 54 MB allocated (Runtime.getHeapUsage after HeapProfiler.collectGarbage).

On top of what QuickJS counts, its small-block arenas (4 KB each, carved into one size class) hold about 28 MB of free blocks after the GC: malloc_history finds 142 MB of arenas and 51 MB of large blocks against 165 MB counted. A build with the arena off (JS_ARENA_LARGE_BLOCKS_ONLY, every block from the system allocator) idles at 380 MB against 368 MB with it, because xzone malloc fragments more (105 MB free in the zone against 78), so the arena stays.

QuickJS-ng 0.18 has no way to strip either. For every function it keeps:

  • The source text, js_strndup’d from the script at parse time for Function.prototype.toString. A function’s text contains every function nested in it, so the copies add up to 39.5 MB for 23.3 MB of scripts. Measured with a temporary patch that frees each function’s source after compiling (NISO_QJS_NOSOURCE, in the patch below): the counted heap goes from 165.5 to 122.5 MB after the same GC, and the run’s peak footprint from 378-388 MB (three runs of mem-breakdown’s build of 725ab09) to 362 MB (three of four runs; the fourth peaked at 304 MB). The workbench came up, but code that rebuilds a function from its text cannot work without it: VS Code’s perfBaseline worker builds its script from fn.toString() and fails (the follow-up below). Bellard’s QuickJS has JS_SetStripInfo for this; QuickJS-ng would need the same, or one refcounted source per script with offsets in each function, which keeps toString and costs one copy (23.3 MB) instead of 39.5.
  • pc2line: 2.4 MB of line and column tables, for stack traces. Small.
  • Names: the file name atom, and each local’s name in the variable table (6.9 MB), which closures need anyway.

QuickJS compiles every function when it parses the script. A temporary patch that marks a function’s bytecode when it is first called (niso_called, a spare bit in JSFunctionBytecode, set in JS_CallInternal and async_func_init) finds that after the workbench is up 101,023 of 119,122 functions were never called. They hold 62.1 MB: 30.0 MB of source, 9.5 MB of bytecode, and 22.6 MB of headers, variable tables, closure tables, constant pools and pc2line. A V8-style lazy compiler keeps one source and a small preparsed record per function until its first call, so it would save most of the 32 MB that is not source (and the source with row 2). The measurement patch is in docs/perf/quickjs-measure.patch; apply it to third_party/quickjs to repeat it.

At idle in iced three workers run besides the window; headless only one starts, and it ends two seconds later (headless runs skip ahead in time when idle, so VS Code’s idle timers stop its editor worker):

Runtime malloc_size at start Notes
A runtime with only the glue (about:blank, before any page script) 4.3 MB 7.3k atoms, 7.1k objects, 3.8k functions (1.8 MB with their source)
Worker 1 10.0 MB atoms 3.4 MB
Worker 2 10.9 MB atoms 3.5 MB
Worker 3 9.2 MB atoms 3.3 MB
The short-lived worker 4.2 MB freed after 2 s

Each worker’s atom table is seven times the glue’s, from something its script or its first messages bring in; that was not traced further.

Measured as a whole, iced idling at the Welcome page for 150 s with workers and with Worker deleted before the workbench starts (VS Code then runs the worker code on the main thread):

Footprint Malloc small Malloc large
With workers (three live) 659 MB 323 MB 61 MB
delete globalThis.Worker 595 MB 279 MB 41 MB

So the three live workers (TextMateWorker, editorWorkerService and LanguageDetectionWorker, by NISO_QJS_MEMORY’s labels at 150 s) cost about 64 MB, 21 MB each. From the allocation stacks of the same idle state (malloc_history, the worker threads’ stacks):

  • QuickJS in the three worker threads: 47.5 MB, against 30 MB counted. A worker’s arenas and malloc overhead are proportionally larger than the window’s.
  • wasm: onig.wasm is instantiated twice, in the window and in the TextMate tokenization worker; each 32 MB linear memory is one zeroed allocation of which about 4.2 MB is touched (vmmap’s dirty size). So 8.5 MB dirty, not 67 MB.
  • The workers’ Doms: 4.1 MB across the three workers’ threads, 1.6 MB of it fontique collections scanned from the system fonts. A worker has no stylesheet, so no stylo cost.

Under the driver (section 4), headless creates and terminates a worker per cycle; each is freed with 16.9 MB counted, and 436 came and went in a 29 minute run. The Worker objects of terminated workers stay held (section 4).

754 nodes live after a full GC at idle headless (509 elements in iced; Chrome counts 684 DOM nodes). From malloc_history’s live allocations (docs/perf/malloc_owners.py --groups and --sites), iced at idle:

Owner MB Per node What
stylo (style, selectors) 27.1 the 1.8 MB stylesheet: selector lists 4.7, PropertyDeclaration blocks 4.1, invalidation map 3.7, rule parser 2.6, custom property values 1.9, selector map 0.8; cascade and computed values under 1 MB
blitz_dom, blitz_traits 2.9 ~3.8 KB node slot map 0.43, text nodes 0.41, elements 0.23, attributes 0.17, taffy’s layout caches about 0.3
fontique 0.4 main, ~0.5 per worker the system font collection scan, per Dom
parley, harfrust, skrifa 0.6 shaping plans and buffers; text layouts are small for this page
niso crates 7.7 niso-core 6.2 (net 1.4, worker setup 1.6), canvas 1.5
sqlite (IndexedDB) 2.3
Glyph rasters (iced’s swash bitmaps) 0.14 47 text images, all small

Mapped but clean, so not in the footprint: ICU’s data file, mapped by CoreText’s font fallback (36 MB), fonts mapped by fontique (20-28 MB) and FontParser (10-18 MB).

tests/apps/vscode-web/memory-driver.js drives the workbench: every cycle opens three untitled JavaScript files, types four functions into each, then reverts and closes them. Runs of 10 to 40 minutes:

Run Start End Cycles Rate
headless (niso-run) 449 MB at 1 min 524 MB at 26 min 430 3.0 MB/min
iced 772 MB at 2 min 950 MB at 40 min ~400 4.7 MB/min
headless, QuickJS’s GC factor patched to 1.125 (725ab09) 402 MB at 1 min 425 MB at 17 min 210 1.4 MB/min
iced, NISO_QJS_MEMORY=120 (a full GC every 2 min) 741 MB at 2 min 722 MB at 10 min 50 window heap after GC 181 to 191 MB
Chrome (page renderer) V8 heap 67 MB V8 heap 70 MB at 35 min

The second iced run stopped cycling after 50 cycles, at blitz’s known “invalid SlotMap key used” panic (layout/mod.rs:160, docs/compat/vscode-web.md), so it only shows the start. The JS heap counted before collection swings between 190 and 380 MB, because of the GC threshold (row 4).

After a full GC the headless window’s heap went from 167 MB idle to 205 MB after 430 cycles, and the handles JS holds from 1,179 to 12,290; nodes live from 754 to 1,011. NISO_JS_HELD=1 (this branch, bindings/js/niso_js.c) prints the held handles by interface after each full GC. After 165 cycles:

Interface Idle After 165 cycles
IDBRequest 207 1,365
IDBTransaction 104 1,216
MutationObserver 0 828
EventTarget 0 668
HTMLDivElement 315 468
CSSStyleDeclaration 195 288
Worker 0 168

So it is a leak, about 7 IndexedDB requests, 7 transactions, 5 MutationObservers, 4 EventTargets and one Worker per cycle, with their JS objects (+38 MB after 430 cycles, about 90 KB a cycle). These are the objects whose on* handlers and observer callbacks reach their own target, the listener cycle docs/listener-cycles.md describes: the callback table is a GC root, the core keeps the callback while the target lives, and the closure keeps the target. Chrome’s heap grew 3 MB over the same driver. The rest of the footprint’s growth is malloc keeping the higher water marks the 1.5x threshold allows; with QuickJS’s factor patched to 1.125 the headless run grew 1.4 MB a minute instead of 3.0.

The “nothing JS ever wrapped is freed” in docs/engines/porffor.md is Porffor’s limitation (it has no GC). QuickJS does free wrappers (the glue’s WeakRef and FinalizationRegistry, docs/engines/napi.md for Node’s), except for listener cycles and custom elements (docs/compat/vscode-web.md).

Metal’s own record of what the app allocated (xctrace’s Metal Resource Events, 150 s from launch) adds up to about 85 MB:

Resource Count MB
iced_wgpu::image texture atlas (2048x2048 RGBA) 1 16.9
iced_wgpu::triangle::msaa attachment (4x) 1 16.6
iced_wgpu.quad.gradient.buffer 22 11.9
CAMetalLayer Display Drawable 2 8.3
iced_wgpu.quad.solid.buffer 22 6.1
iced_wgpu::image instance and uniform buffers 72 9.4
iced_wgpu::triangle::msaa resolve target 1 4.3
iced_wgpu::quad uniforms buffer 22 2.9
staging belts, glyphon (cryoglyph) atlas and buffers, triangle buffers ~5

The per-layer buffers come from iced making one layer per clip: each layer gets quad buffers sized for 2,000 instances. The MSAA target is for iced’s triangle pipeline (paths and meshes); iced’s settings turn antialiasing on by default and niso-iced does not change it.

footprint also shows 4 to 46 GPU allocations of exactly 4 MB (256 pages) owned by the process but mapped in none of its address space, which come and go over tens of seconds and are not in Metal’s record. Their size does not follow the window (640x400 has 42 of them too), so they are not drawables, and the SwiftUI host, which has no wgpu, has 24. Who creates them needs footprint --unmapped, which needs root. niso-iced asks for a frame every vsync (window::frames()), so an idle Welcome page costs 35% of a core and redraws continuously; drawing only when the render stream changes is the first experiment.

The 212 MB figure from the first measurement was one of the high samples.

niso_iced::text::Fonts::register copied each font the render stream registers into a Vec for swash (13.4 MB), though the core already holds those bytes in fontique’s mapped files. Fixed (the follow-up’s “Fonts in the iced host”). The text bitmaps themselves are small (47 images, 0.14 MB at idle).

A prebuilt bundle from the swiftui-multiplatform work, dated 2026-11-09 22:05; its exact commit is not recorded, so these numbers predate that branch’s later changes.

At 90 s, 641 MB (705 MB with malloc stack logging’s own 64 MB):

Part MB
QuickJS (window 217 + workers 47) 265
Apple frameworks and Swift: AttributeGraph 12, SwiftUICore’s view lists, layouts and modifiers ~10, Swift dictionaries and sets 6, CoreFoundation, objc 58
Untagged (AttributeGraph’s tables, 88 MB reserved) 37
wasm 45 allocated, ~8 dirty
stylo 26
Graphics: 24 of the 4 MB objects (96 MB), CALayer backing and IOSurfaces 110 + 6 + 4

The 311 MB measured at 70 s was before VS Code’s workers and timers had run.

Chrome 154.0.8037.58 with a fresh profile on the same page, idle at the Welcome page after 70 s (footprint per process, SystemInfo.getProcessInfo for the types, Runtime.getHeapUsage per target after a forced GC):

Process Footprint
browser 117 MB
GPU 54 MB
renderer, the page 169 MB: V8 heap 52 MB used (54 MB allocated); 684 DOM nodes, 1,016 listeners
renderer, the extension host iframe (vscode-cdn.net, out of process) and its worker (2.1 MB heap) 37 MB
renderers, spare and service 52 + 26 MB
network, storage, passage embeddings services 27 + 19 + 49 MB
Total 550 MB

Under the driver the page’s renderer went to 265-281 MB and the GPU process to 67-292 MB, while its V8 heap stayed at 67-72 MB.

The page’s renderer is the one to compare with niso: 169 MB holds what niso-run holds in 370 MB. V8’s heap is a third of QuickJS’s for the same JavaScript (rows 2 and 5: no per-function source, lazy compilation, and denser objects), and Chrome’s renderer has no copies of the bundle left over.

All on macOS; Linux has heaptrack and DHAT instead of malloc_history.

Terminal window
cd vscode-web # the standalone build, as tests/apps/vscode-web/fetch.sh fetches it
# QuickJS's own accounting, each engine:
NISO_QJS_MEMORY=60 niso-run --page app.html --root . --url http://localhost/app.html --max-ms 30000 --time
# Live allocations by owner: --hold keeps the process alive until stdin closes.
MallocStackLogging=lite niso-run --page app.html --root . --url http://localhost/app.html --time --hold &
malloc_history PID -allBySize > live.txt
python3 docs/perf/malloc_owners.py live.txt --groups # or --threads, --sites style,selectors
footprint PID; vmmap -summary PID # dirty memory by category, malloc zone fragmentation
# Held handles by interface after each full GC:
NISO_JS_HELD=1 niso-run --page memory.html ... --time
# The driver for growth runs:
node tests/apps/vscode-web/make-index.mjs vscode-web tests/apps/vscode-web/memory-driver.js memory.html
# GPU resources the app allocates:
xctrace record --instrument "Metal Resource Events" --time-limit 150s --launch -- niso-iced --page app.html ...

footprint’s categories include malloc stack logging’s own “Performance Tool Data” (64 MB here); subtract it. malloc_history’s allocation sizes for large zeroed blocks (wasm memories) are reserved sizes; vmmap gives their dirty pages.

What the architecture agents would need to change

Section titled “What the architecture agents would need to change”
  • Load without copies (row 3): Net::load_local (niso-core net.rs) copies the loader’s bytes, and Dom::out_bytes (ffi.rs) copies them again into the C ABI buffer for niso_dom_load_script (loading.rs). An owned buffer handed through, NUL-terminated for QuickJS, removes two 20.7 MB copies of the workbench, and niso-run’s loader could map the file.
  • Listener cycles (row 11): the redesign in docs/listener-cycles.md, or, short of it, the core dropping the listeners of an object that can no longer fire (a finished IDBRequest, a finished IDBTransaction, a disconnected MutationObserver, a terminated Worker).
  • Worker Doms (row 8): no fontique scan until a worker draws text (OffscreenCanvas).

Follow-up, 2026-11-10: what is not the engine, and VS Code on V8

Section titled “Follow-up, 2026-11-10: what is not the engine, and VS Code on V8”

Branch mem-quickwins, on beako unless noted. The QuickJS-only experiments at the end were measured and then dropped, since VS Code moves to V8 (node-windows) and QuickJS stays as it is for small apps.

macOS’s malloc keeps freed memory dirty: the three freed copies of the bundle sit in “Malloc Large (empty)” (73 MB), and freed small blocks in the small zone. malloc_zone_pressure_relief returns none of it (niso-run --time now prints the footprint before and after calling it: 367.2 and 367.2 MB). Two settings libmalloc reads at process start do return it:

Headless, Welcome page (3 runs each, mem-quickwins’s build of 36ce7a6) default MallocLargeCache=0 MallocSpaceEfficient=1
Idle footprint 370-373 MB 222-227 MB 181-184 MB
Peak footprint 370-373 MB 266-267 MB 244-245 MB
Growth driver, 60 cycles, footprint sampled each minute 438-468 MB 327-331 MB 266-268 MB
Driver time, cycles 10 to 60 (the three ran together) 156.2 s 160.0 s (+2.4%) 162.1 s (+3.8%)
VS Code in a niso window on V8 (node-windows), 70 s 665 MB 562 MB

With MallocSpaceEfficient=1 vmmap shows 3 MB of Malloc Large against 77 MB, and 175 MB of Malloc Small dirty against 289 MB. Being read at process start, they belong in the launch environment: LSEnvironment in a macOS app’s Info.plist, or the environment the node running niso is started with. iOS apps cannot set them; not tried there.

On Linux (yachiyo, glibc, niso-run at main 36ce7a6) the idle Welcome page is 280 MB RSS, and malloc_trim(0) (called from gdb at exit) takes anonymous memory from 240.7 to 235-236 MB: glibc already unmaps large freed blocks. So calling malloc_zone_pressure_relief or malloc_trim after a load or under memory pressure is not worth wiring into the hosts: it returns nothing on macOS and 5 MB on Linux. niso-run calls it only to measure.

Trivial workers (a script that posts one message), kept alive:

Per worker Of which
QuickJS (niso-run, 16 workers) 7.1 MB footprint engine 5.2 MB of malloc (4.2 counted by QuickJS: the glue); core 0.84 MB (fontique’s system font scan 0.53, CoreText under it 0.24, niso-core 0.02); stylo and blitz nearly nothing (no style sheet); stack 0.1; malloc slack about 1
V8 (niso window, 8 workers) 13.9 MB footprint the worker’s isolate 10-14 MB physical (8.5-10.5 MB used: Node’s worker bootstrap and the glue); malloc 3.9 MB

The core’s part per worker is under 1 MB, most of it the font scan, which worker Doms could skip until they draw text (architecture-owned). VS Code’s own workers cost 15-20 MB of V8 heap each at idle (21 MB each in QuickJS, section 2): their scripts, not niso.

Fonts::register copied every font file the render stream named (13.4 MB in niso-iced, 14.0 MB of niso_iced malloc in the node-windows window). Dom::render_font_blob now hands the host the core’s own Blob, so the host keeps a reference: niso_iced’s live malloc at idle is 1.4 MB. The node-windows branch has the same copy (engine.rs and text.rs), which this change replaces when the branches merge.

26.2 MB (QuickJS) and 27.5 MB (V8) for 16 style sheets with 11,910 rules and 2.33 MB of rule text, 10,029 of the rules in the workbench’s sheet: selectors 4.9 MB, declaration blocks 4.1 (6.8 in row 10, which adds the values parsed into them), the invalidation map 3.7, nested rule blocks 2.6, custom property values 2.1, hash tables 1.5, atoms 0.9, the selector map 0.8. The sheets are parsed once (there is no second copy for the CSSOM), about 2.2 KB a rule, as stylo keeps them; nothing to cut in niso.

node-windows 3476f87, VS Code in a 1280x800 window, 70 s after start, one run at 665 MB (Subagent 108 measured 654-781 MB, median 729):

Part MB What
V8 heaps 219 the window’s isolate 146.5 (144 used: the bundle’s source 41.4, code 40.8, other strings 13, objects, shapes, arrays and closures 47, plus onig’s 16 MB wasm memory outside the heap); three worker isolates 53 (15-20 each); the rest 20
Graphics 279 GPU memory mapped nowhere in the process 217 (138 regions), IOAccelerator 50, IOSurface 12
Malloc 132 109 live: stylo 27.5, niso_iced 14.0 (the font copies, fixed above), niso-core, canvas and the rest of niso 12, text 4.7, blitz 4.6, Node, V8 and Apple’s frameworks about 43 (sqlite for IndexedDB 4.8, Metal 4.2, ArrayBuffers 3.9)
Page tables 20
Node itself 12-16 a bare node, and with the addon loaded

The bundle’s source is one 41.4 MB string: V8 keeps a script’s source two bytes a character when any character is above U+00FF, and 3 of the bundle’s 21.7 million are (。, ! and ।). Escaped as \u sequences, which is what esbuild’s charset: 'ascii' writes, it would take 20.7 MB. That is a build setting of the app, not niso’s.

The graphics are the same in niso-iced on QuickJS (215 MB unmapped in 126 regions at 60 s, and the same with --no-text), and 18 MB in 15 regions for a page with one paragraph, so they follow the page’s content, not the window size or the text images. GPU-private buffers and textures (iced’s per-layer quad and image buffers, the atlas, MSAA) are what a process owns without mapping; attributing them needs footprint --unmapped, which needs root. They are the largest item here that is neither the engine nor the app.

Measured before the change of direction, headless Welcome page, with a build of 36ce7a6 that had the experiments compiled in and off by default (367 MB idle and peak with all of them off):

  • GC threshold (QuickJS’s 1.5x, set again after each GC from qe_run_microtasks): idle 367-371 MB at 1.125x, 1.25x and 2.0x against 367 MB. This version never ran the growth driver; the driver run at 1.125x in row 4 and section 4 is the earlier one, with QuickJS itself patched.
  • Source stripped by writing compiled code without its source and reading it back (QuickJS-ng has no strip option; only its writer drops source): counted heap 164 to 122 MB, idle 250-256 MB against 367, peak 415-425 MB against 367. VS Code’s perfBaseline worker then fails: it builds a worker from fn.toString().
  • Rewritten with source kept: idle 291-300 MB with a 493-503 MB peak when the copy is read while the compiled one is still alive; 359-367 MB when it is not. The gain was the freed compile’s memory going back, not the code.
  • Lazy compilation: 101,023 of 119,122 functions were never called; they hold 32 MB besides 30 MB of their source. An engine change, for quickjs-ng upstream or with step 19’s rquickjs.