Skip to content

Faster server rendering: parse each module once per build, and trim per-render JSON and parsing work - #5

Merged
davidwhitney merged 8 commits into
davidwhitney:mainfrom
lahma:perf/server-rendering
Aug 7, 2026
Merged

Faster server rendering: parse each module once per build, and trim per-render JSON and parsing work#5
davidwhitney merged 8 commits into
davidwhitney:mainfrom
lahma:perf/server-rendering

Conversation

@lahma

@lahma lahma commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Server rendering does the same work more often than it needs to: every pooled engine re-reads
and re-parses the whole module graph from disk, every render parses two small throwaway scripts,
the model and the context are each serialised twice per response, and the rendered markup is
JSON-escaped inside the engine only to be unescaped again on the way out. This PR removes each
of those, keeps two behaviours a pooled engine should have but didn't (a clean global surface
between renders, a timeout that actually bounds the render), and updates Jint to 4.15.3, which
is what makes the pooling work possible.

Each commit stands alone and keeps the suite green, so any of them can be dropped or cherry-picked
if you'd rather take a subset.

What changed

  1. Update Jint to 4.15.3 — no code changes; brings the engine-reuse APIs used below plus
    4.15.2's async/bound-function fixes.
  2. Parse each module once per build, not once per pooled engine — a build-scoped cache of
    prepared module ASTs (thread-safe and shareable per Jint's documentation) shared by every
    engine in the pool. The npm CommonJS→ESM transform now also runs once per build instead of
    once per engine. Invalidation rides the existing build id, so a rebuild starts fresh exactly
    as it does today.
  3. Stop re-parsing the host shims and per-render scaffolding — the shims are parsed once per
    process; the per-render ({}) and JSON.parse(...) evaluations are replaced with direct
    object construction and Jint's own JSON parser (the same parser JSON.parse uses, minus a
    script parse, an engine entry and two global writes per render).
  4. Serialise the model and context once per response — the document writer and the engine
    used to serialise the same model and context independently; now both consume one pair of
    strings. In Server-only mode the second serialisation was pure waste.
  5. Return server markup directly instead of through JSONrenderView hands the markup
    back as the string it already is; only the small head descriptor still travels as JSON. A
    whole page no longer gets JSON-escaped and unescaped per render.
  6. Restore a clean global surface when an engine returns to the pool — replaces the manual
    bridge cleanup with a snapshot restore, which also removes anything a view left on
    globalThis (previously it stayed in the pooled engine indefinitely) and fences stray async
    continuations from settling into a later request's render.
  7. Enforce the render timeout across the whole render and honour aborted requests — the
    engine re-arms its built-in timeout at every entry from the host, and one render enters the
    engine several times, so ServerRendering.Timeout really bounded each entry rather than the
    render; a render could legitimately consume several multiples of it. It is now one budget for
    the whole render, and HttpContext.RequestAborted now stops running JavaScript instead of
    only cancelling the wait for a pool slot. Behaviour change: a view that only fit inside
    the old per-entry budgets can now time out — ServerRendering.Timeout is the knob.
  8. Let a host declare immutable types crossing the globals bridge — kept internal on
    purpose; see the question below.

Measurements

BenchmarkDotNet 0.15.8, default job, [MemoryDiagnoser], .NET 10.0.10, Windows 11, serial runs
on an idle machine. The harness builds a real view project with the real TypeScript compiler and
renders through JsxServerRenderer — five rows: a minimal view and a 20-record dashboard view on
a warm engine, the head-only path, a cold start (Reset() + first render, i.e. one engine built
from nothing), and a pool-growth burst (Reset() + four concurrent renders, i.e. how the pool
fills under load).

Preact (baseline d128b52 → this PR)

Row Before After Time Allocated
RenderSteadyState_Minimal 117.0 μs / 49.9 KB 104.9 μs / 47.4 KB −10.3% −5.0%
RenderSteadyState_Dashboard 2,514 μs / 2,605 KB 2,451 μs / 2,582 KB −2.5% −0.9%
ReadHead 94.2 μs / 47.8 KB 86.2 μs / 46.3 KB −8.5% −3.1%
ColdStart 5,220 μs / 4,401 KB 6,114 μs / 4,522 KB +17.1% +2.8%
PoolGrowthBurst 4,112 μs / 6.58 MB 3,755 μs / 3.14 MB −8.7% −52%

React (baseline → this PR)

Row Before After Time Allocated
RenderSteadyState_Minimal 234.2 μs 224.4 μs −4.2% −5.2%
RenderSteadyState_Dashboard 7,459 μs / 1,031 KB 7,019 μs / 1,005 KB −5.9% −2.5%
ReadHead 93.6 μs 91.6 μs −2.1% −3.2%
ColdStart 117.0 ms / 68.9 MB 140.3 ms / 72.4 MB +19.9% +5.2%
PoolGrowthBurst 234.4 ms / 264.3 MB 187.8 ms / 170.4 MB −19.9% −36%

Reading the cold-start regression honestly

The parse-once cache prepares a module (Jint's PrepareModule, which runs a static-analysis
pass) where the engine used to just parse it. For a single engine built alone there is
nobody to share with, so that first build pays the analysis and gets nothing back: +17%
Preact, +20% React
on the ColdStart row. Every further engine built for the same compilation
skips parsing entirely — the burst row is four engines, and allocation halves. Where each case
lands in practice:

  • Production (precompiled): the tax is paid once per process by the first render; every
    pool-fill after that is the burst case. Net win.
  • Development: a rebuild drops the pool and the cache, so the next render pays the tax once
    per edit — ~0.9 ms for Preact, ~20 ms for React, alongside a recompile that costs tens of
    milliseconds anyway. Subsequent pool growth for that build shares the one parse.

Steady-state rows are unaffected by the cache either way (a warm engine keeps its module graph,
as it always did).

Two per-commit notes for completeness: commit 4's row-level effect is a couple of microseconds
of extra work at the JsxServerRenderer API (two small JSON parses instead of one combined) —
its saving is at the response level, where the document writer no longer re-serialises the model
and context; and the clean-globals restore plus the whole-render deadline both measured within
noise of free on every row.

A question on commit 8 (ImmutableCrossingTypes)

Jint 4.15.3 lets a host declare CLR types whose instances are immutable while exposed; declared
types get their member reads memoised, which pays off for registered globals whose methods hand
back records a view walks repeatedly. The wiring and tests are in this PR, but the option is
internal: it is a promise (a wrong declaration is answered with stale reads, not an error),
so whether to expose it publicly — and under what name and shape — feels like your call rather
than mine. Happy to make it public in this PR, move it to a follow-up, or drop the commit
entirely.

Testing

  • Full suite passes at every commit (the one pre-existing failure on my machine,
    NpmBootstrapTests.ResolveProjectDirectory_NoManifestAnywhere_FallsBackToTheContentRoot, is
    environmental — a stray package.json in my user profile that the manifest walk-up finds —
    and fails identically on the base commit).
  • New tests: view litter on globalThis is gone by the next render; a never-returning view is
    ended by the configured timeout (and the exception says which budget fired); a request aborted
    mid-render stops running JavaScript; immutable-crossing declarations cut member re-resolution
    (asserted by counting CLR getter invocations, 2 → 1).

@davidwhitney

Copy link
Copy Markdown
Owner

@lahma this looks awesome, I'll try get it in over the next day or so!

@davidwhitney
davidwhitney marked this pull request as ready for review August 7, 2026 01:08
@davidwhitney
davidwhitney merged commit be0068f into davidwhitney:main Aug 7, 2026
@lahma
lahma deleted the perf/server-rendering branch August 7, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants