ReviewOS

stacks/stx

SSR renderView churns ~12MB of mostly off-heap allocation per render of an unchanged template

#1945
Closed chris opened this 10 days ago ·
10 days ago

Found gating server memory in stacksjs/harness (harness#11 has the full history). Rendering the same ~1,700-line view against identical props, per render:

  • bun:jsc heapStats shows only ~1.1MB of JS-heap allocation and nothing retained after GC — the projection is fine.
  • Process RSS climbs 30→209MB over the first ten renders and plateaus around 267MB, never returning to the OS — allocator high-water, not a leak. Forced Bun.gc(true) after every render caps almost nothing (253→224MB over 60 renders), so the churn is native-side (transpiler/bundler/regex/WTF-string territory), invisible to the JS heap.
  • A template containing one component tag (<Sidebar> from @stacksjs/components) produces a 211KB page — 192KB of it inline script — and burns ~9MB/render. A trivial <div></div> still churns ~1.4MB/render.

Where it goes: the client-script bundler's disk cache hits every render (verified with STX_DEBUG), but everything downstream of it re-runs per render on unchanged inputs:

  • the ~190KB cached bundle is re-read from disk, re-wrapped and re-inlined,
  • the DOM-API validator re-scans the full client script (its warnings print once per render),
  • scope ids (stx_sidebar_<n>_<rand>) regenerate each render, so the output is never byte-identical and can't be memoized by callers from the outside,
  • the island wrapper is rebuilt.

The compiled island artifact is a function of (template content, config) only — the scope id is already threaded in as an IIFE argument (})(\"stx_sidebar_4_15gdi7\")), so the whole post-bundler artifact looks memoizable keyed by content hash, with the scope id (and data-stx-props) interpolated at render time. That would turn SSR of an unchanged template into string interpolation and cut per-render allocation by roughly an order of magnitude.

Numbers from bun 1.3.14, stx 0.2.188, macOS arm64. Reproduction is just renderView of any island-bearing view in a loop, watching ps -o rss= against heapStats().heapSize.

Downstream we now cache rendered pages keyed by a state revision (stacksjs/harness@75482a4), which covers polling — but every genuine state change still pays the full churn, and any hot stx SSR surface without an equivalent cache pays it per request.

🤖 Generated with Claude Code

10 days ago

Update, with measurements — and one part of this is not what you asked for, so it is first.

Latency is much better; allocation is not

Measured against 673d28030e (the day before you filed), on a page of the shape you describe — one <Sidebar> from @stacksjs/components, ~212KB out — 300 renders each, after settling:

ms/renderRSS growth per renderJS heap
673d28030e34.451.7 KBflat
today6.4256.6 KBflat

5.3x faster, and allocating more per render, not less. Memoisation trades memory for time, so the axis your title names has moved the wrong way. What I can say is that it is bounded rather than unbounded: over 800 renders RSS rose to 229MB, came back down to 176MB and stayed there, with the JS heap flat throughout. That is not the "plateaus around 267MB, never returning to the OS" you saw — but your numbers came from a real server and a ~1,700-line view, and mine from a synthetic page on bun 1.3.14, so treat the shape as comparable and the absolute numbers as not.

What landed

Your read of the artifact was right — it is a function of (template content, config) with the scope id threaded in — and that is roughly what the fix does, in three parts:

  • cd2672ec5e — a component carrying a <script client> can now opt into <script server cache> at all. It could not before: the gate also required every script in the file to be a server script, which excluded exactly the island-bearing components the cost sits in. Lifting that needed the cached entry to replay what a render does and not only what it returns. The load-bearing one is the client-factory registry: registerComponentClientFactory counts instances and injectComponentClientFactories inlines a definition over its call when the count is one, so a hit that did not register shipped a call to a factory nothing defined — the component silently did not hydrate, and the page stopped being byte-identical. 216,289 bytes on the render, 198,417 on the "repeat" of it, before that was fixed.
  • f1c96624a6 — Sidebar, SidebarSpaces and SidebarSpaceSwitcher opt in, after checking they earn it. The check that mattered was not per-file purity but that a hit serves the whole subtree from one entry: it is not enough that Sidebar ignores the route, every row under it has to as well, or a cached tree carries one page selection onto the next. It does — findActiveSidebarHref is imported by the <script client> block. On that page: 84.7ms to 44.4ms under load, byte-identical.
  • 50fe0d2e38 — the consequence of the above, and the part most relevant to you. LRUCache bounded by entry count, which describes a footprint only while entries are one size. Once island-bearing components became cacheable, 2000 entries stopped being a few MB. On a component rendering 290KB, at 400 distinct prop sets — a fifth of the ceiling — RSS grew 39MB against 7MB with a budget, and the old bound would have let that one cache reach ~195MB. It now takes a byte budget (32MB) alongside the count, whichever binds first.

Nothing to do on your side for the Sidebar part; it ships opted in.

Left open, because the title is not addressed

Per-render allocation is not reduced, and I do not think another cache reduces it. What is left is materialising a ~212KB output through intermediate copies across the directive pipeline — the profile is flat now, top frame 4.3%, no hot stage to memoise. Cutting that means not building the intermediates: a streaming or rope-based renderer, which is an architectural change rather than an optimisation, and not one to start without agreeing it is worth it.

Two things worth your input:

  1. 32MB is a guess and is not configurable. You are the memory-constrained host this is for — if you want it in stx.config.ts, say so and it goes in.
  2. Your downstream revision-keyed page cache may be doing less for you now. A state change costs 6.4ms rather than 34.4ms on this shape. Probably still worth keeping, but the trade has changed.

If the memory ceiling is still what gates you rather than the latency, say so and the streaming question moves up the list.

10 days ago

Follow-up to the update above, where I had to report that latency was 5.3x better and allocation was worse. That is now addressed.

Method

RSS was the wrong instrument, as you found — it is allocator high-water, and at my scale it is noise-dominated: peak RSS over 400 renders varied 172–255MB across three runs on both sides of a change, with wall-clock indistinguishable. So I measured allocation directly instead, with two profilers that need no source edits:

  • wrap the String.prototype methods that materialise a new string, attribute each result over 4KB to the call site above it, and record one steady-state render. This catches exactly the "WTF-string territory" churn you named, which the JS heap does not show.
  • wrap the Function constructor and count compiles per render, since new Function allocates a code object off-heap and is invisible to a heap profiler.

Four fixture shapes, each rendered 6 times to settle, then one measured render.

Results

Per steady-state render of an unchanged template:

fixturestring allocationnew Function compiles
component-dense (237KB out)107.57 MB → 2.65 MB5,666 → 217
expression-dense18.88 MB → 1.34 MB385 → 1
stores + composables6.02 MB → 1.46 MB—
plain signals page3.53 MB → 1.17 MB7 → 1

Eight commits, 4272fecb43 through e632ef28d4. Every one verified byte-identical against its parent across 15 renders (5 page shapes × SEO on / off / colorMode), and by the full suite.

What it actually was — not what either of us expected

Your hypothesis was that the post-bundler island artifact should be memoized by content hash. I did not do that, because the profiler pointed somewhere else and much larger.

findComponentTags was 95.5 MB of the component-dense page's 107.6 MB — 89% of everything the render allocated. It walks every < in the document, and to test whether a tag name matched it sliced the entire remaining document and ran an anchored ^... pattern over the copy, plus a fresh new RegExp per character. 4,250 slices averaging 23KB. A sticky matcher does the same test at an offset and copies nothing (98be4f10e9).

The rest, in order:

  • findFirstScriptTag did the same slice-per-<, plus a html.toLowerCase() per <style> to find its closing tag. 2.4MB on a page with no components at all (34fc01c530).
  • The x-cloak pass lower-cased the whole document once per script and once per style, to locate one closing tag (43651bec11).
  • processExpressions restored masked <script>/<style> bodies with one whole-document replace per block — 74 rebuilds of the page (eecdeed4ca).
  • Expressions were recompiled on every evaluation: 5,666 compiles for 106 distinct sources, 1.7MB of source per render (e632ef28d4).

Three earlier commits (4272fecb43, 53d7f2e6ae, 132ef770ef) collapsed the small head/script injections that each rebuilt the whole document to insert a few hundred bytes. One of those is worth calling out on its own: <meta charset> was displaced by injectors and then hoisted back at the end of the pipeline — a 186KB document rebuild, on every render of every page, to move three bytes.

What is left, honestly

  • The largest remaining site is processSignals inserting the ~175KB client runtime — a copy that has to happen (7% waste).
  • The 217 remaining compiles are the same few sources under different parameter lists, one per component instance. Closing that needs a fixed calling convention, not a cache.
  • I have not shown that process RSS moves. These are churn reductions, and nothing here was retained; the plateau you measured may well be unchanged. If the harness numbers in stacksjs/harness#11 still look bad after this, that is a separate finding and worth saying so.
  • Your island-memoisation idea is still open and still sounds right; it is now a smaller slice of a much smaller total.

Two bugs fell out of the profiling and are filed separately: an uppercase <STYLE> is read as a PascalCase component and replaced with a component-not-found error, and the injected composable bundles emit in an order that contradicts the comment documenting it.

10 days ago

Follow-up to the 09-08 update. The first part corrects that comment rather than extending it, so it goes first.

Correction: the "string allocation" numbers were not allocation

On 09-08 I said I had "measured allocation directly" by wrapping the String.prototype methods that return a new string, and led with 107.57 MB → 2.65 MB, most of it findComponentTags' "4,250 slices averaging 23KB". That overstated it.

A slice copies no characters in JSC. It shares the parent's buffer, and that holds at every size I measured, not only large ones. Retaining 48 MB of content, each case in its own process, next to a control that forces a real copy of the same characters:

slice sizekept as sliceskept as forced copies
256 BRSS +17.1 MBRSS +124.7 MB
4 KBRSS +2.5 MBRSS +78.6 MB
23 KBRSS +1.8 MBRSS +69.0 MB

A slice costs one string object (~90 bytes resident at 256 B), not its length, and the instrument counted length. Real churn is where characters have to be written: flattening a concatenated rope (20 ropes over a 38 MB string: +0.1 MB left as ropes, +742 MB once forced flat), replace, toLowerCase.

For 09-08 that means: the findComponentTags fix still removed real work (a new RegExp per character and an anchored regex per <), but its 95.5 MB was never allocated bytes. The whole-document replace rebuilds it listed were real. The compile counts (5,666 → 217) came from a different instrument and stand.

Reproducible in packages/stx/test/performance/string-cost-model.ts.

So the goal in the title is not demonstrated

Nothing in this Bun build measures cumulative allocation: heapStats doesn't see string storage, mimalloc's stat counters are compiled out, and RSS is high-water. I can't show a drop in what your title names, and I'd rather say that than keep quoting a proxy that overstates it.

What is solid

Thirty commits since e632ef28d4 (5dc2f3a252 … db32fc6618). Measured over the last round, from 37194e1960:

  • Byte-identical output, checked by sha on four fixture pages against 37194e1960's source in one tree (comparing two checkouts instead makes every sha differ, because gitignored build output differs), and on the pages each later change touched.
  • Time, which is what the proxy has to be checked against:
    • sidebar-arc-spaces (one <Sidebar>, ~397 KB out): median ~27.4 → ~21.8 ms per render.
    • form-examples: the x-cloak pass read each of its 7,326 tags in place instead of slicing it out: 70.95 → 64.92 ms median (one A/B run each). TypeScript transpiles are now memoised per transpiler; Bun's native transformSync was the top self-time frame in a CPU profile. That's −6.6% CPU per render, 6/6 interleaved pairs. Measured as CPU time because the host was at load average 12, where wall-clock swung 66–184 ms for identical code.
  • Suite 12,857 pass / 0 fail.

Most of it came from finding where the pipeline re-derives content that hasn't changed, not from caching output. 4 of 5 script restores per render rebuilt a document nothing had edited, and one document's script tags were walked three times.

One thing the profiling found that was not a perf problem

fallbackVariableExtraction was 7.7% of render time on a page that should never reach it. It reaches it because six shipped components -- Button, TextInput, EmailInput, Textarea, Form and Checkbox -- failed to run their <script server> on every render, silently. A JSX-style object prop is split across whitespace into separate context keys, one of which is true; that is identifier-shaped but reserved, so it was handed to new Function as a parameter name and threw before the script body ran. The catch reads that as "the script failed" and falls back to static extraction, which cannot call a helper, so exports computed by calling one came back undefined and the component rendered its empty branch.

Fixed in ffeaec7058: one shared rule for what can be a generated function's parameter name, used by every caller that spreads a template context into new Function. Three other call sites had the same hole, including the fallback path itself. 8 silent failures per render on that page before, 0 after. The prop loss that started it is filed separately as #1956.

Next

  • The measurement that settles this is yours. harness#11 is where RSS gated a real server. On 09-08 I said I hadn't shown RSS moves, and that's still true. If you can re-run that measurement on current main, that decides whether this closes.
  • One lead, better as its own issue: in a CPU profile of form-examples, fallbackVariableExtraction takes 7.7% of render time. A fallback that expensive suggests the primary path is failing on something, which may be a correctness question as much as a speed one. Not investigated yet.

Leaving this open until the harness numbers are in.

🤖 Generated with Claude Code

10 days ago

Closing this, with the one measurement the issue actually asks for and that I had been deferring to your harness: process RSS.

50 renders of one unchanged view, three runs per side, interleaved on the same machine. Baseline is 673d28030e, the day before you filed:

673d28030emain today
peak RSS263 / 286 / 308 MB213 / 215 / 215 MB
settled after Bun.gc(true)224 / 272 / 227 MB215 / 191 / 210 MB
growth per render1192 / 2170 / 1316 KB1156 / 818 / 1381 KB

Peak RSS is down about 25%, roughly 286 → 215 MB, and it stopped wandering — three baseline runs spread across 45 MB, three current runs across 2 MB. The plateau you measured is lower and steadier.

Per-render growth is unchanged within noise. The three pairs overlap, so I am not claiming that number moved.

One caveat on the comparison: the baseline predates a CSS-engine dependency swap (Crosswind → ts-css), so some of that 70 MB is the dependency stack rather than this work. I cannot separate the two without rebuilding the old stack, and I would rather flag it than quote the number clean.

What the work actually amounts to, restated honestly:

  • Latency: ~27.4 → ~21.8 ms per render on the Sidebar page. That is the ground truth and it held up under repeated measurement.
  • Peak RSS: lower and stable, as above.
  • The "‑38% allocation" figure is not allocation and I retracted it in the comment above: a slice shares memory rather than copying at every size I could measure, so that counter tracks materialised content, not allocated bytes. Nothing in this Bun build measures cumulative allocation.
  • Output stayed byte-identical throughout, verified by sha across four fixture pages against the pre-change source in one tree.

Also fixed along the way, found by profiling rather than by the title: six shipped components silently failed to run their <script server> on every render (ffeaec7058), which is what made fallbackVariableExtraction 7.7% of render time.

If your harness still shows the old plateau, please reopen — that would mean the shape there differs from this fixture, and it is your numbers that decide it, not mine.

🤖 Generated with Claude Code

Sign in to comment on this issue.