Skip to content

perf(router): benchmark lazy-include double-run; record LP3 deferral with data - #545

Merged
ivogt merged 1 commit into
mainfrom
perf/router-lazy-include-benchmark
Jun 8, 2026
Merged

ivogt merged 1 commit into
mainfrom
perf/router-lazy-include-benchmark

Conversation

@ivogt

@ivogt ivogt commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What & why

No behavior change. This adds the lazy-eval / loadManifest / cold-start timing benchmark that docs/internal/matching-stability-review.md explicitly asked for, and uses it to turn the vague LP3 "needs-design" item into a measured decision to defer.

LP3 is the match-time + render-time double handler execution for a non-leaf include — a module with path() routes alongside a nested include(), so it is never precomputed and runs its handler once in evaluateLazyEntry (match) and again in loadManifest (render).

The numbers (vitest bench lazy-include-cost)

phase 5 routes 50 routes per-route
construct (baseline) 0.40 µs 0.41 µs —
match — the LP3 redundant run ~7.1 µs ~39.4 µs ~0.75 µs/route
render cold (unavoidable) 17.0 µs 87.2 µs —
warm (cached, every later request) 0.80 µs 1.84 µs —

Reading: the redundant run is ~0.75 µs/route and ~50% of the manifest-build cost — but manifest-build is itself microseconds, and a real RSC first request (render + Flight + SSR) is milliseconds, so the waste is < 0.5% of a cold first request. It's paid once per route per isolate and amortizes to ~0 (warm cache hit ~0.8–1.8 µs).

Decision: defer LP3

  • Common case is already 1 run — leaf includes are precomputed (C8/LP2); only the render run executes. The residual double-run is a non-leaf include's direct routes.
  • Removing it is entangled with LP4. The match-time tree is built in a throwaway "lazy" namespace without isSSR (which isn't even known at match time), so reusing it for render requires splitting isSSR-dependent state out of the cached tree — the LP4 refactor, touching shortCodes and the semantic matrix.
  • Risk ≫ reward for a sub-µs-per-route, amortizes-to-zero cold-start cost. Deferred as a documented decision, not planned work. LP4 is the gate for ever revisiting it.

Changes

  • src/router/__tests__/lazy-include-cost.bench.ts (new) — vitest bench: construct / match (LP3 waste) / render-cold / warm across 5- and 50-route non-leaf includes. .bench.ts, so excluded from the default vitest run.
  • lazy-include-perf.test.ts — clarify the Part B 2 sentinel simulates the pre-C8 (shortcut-disabled) path and is expected to stay 2; rewrite the LP3 it.todo with the measured numbers + deferral rationale.
  • docs/internal/matching-stability-review.md — LP3 section rewritten as MEASURED / DEFERRED with the numbers and the LP4 entanglement; Follow-up updated (benchmark done; LP4 gates any LP3 revisit).

Test plan

  • typecheck clean (covers the .bench.ts)
  • unit 3383 pass / 6 todo — bench correctly excluded from the run (185 files, unchanged)
  • lint + format clean; pre-commit (lint/format/e2e-bucketing) green
  • bench runs: vitest bench lazy-include-cost produces the table above
  • no behavior change → no new e2e; existing suites unaffected

Notes

…with data

No behavior change. Adds the timing benchmark the matching-stability review asked
for and turns the vague LP3 "needs-design" item into a measured decision.

LP3 is the match-time + render-time double handler execution for a non-leaf
include (a module with path() routes alongside a nested include(), so it is never
precomputed). Investigation + measurement narrowed and quantified it:

- The common case is already 1 run: leaf includes are precomputed (C8/LP2), so
  only loadManifest runs the handler. The residual double-run is a non-leaf
  include's DIRECT routes.
- Measured (lazy-include-cost.bench.ts, `vitest bench lazy-include-cost`):
  the redundant match run is ~0.75us/route (~7us for 5 routes, ~39us for 50) —
  ~50% of manifest-build time, but manifest-build is microseconds and a real RSC
  first request is milliseconds, so the waste is <0.5% of a cold first request.
  Paid once per route per isolate; amortizes to ~0 (warm cache hit ~0.8-1.8us).
- Removing it is entangled with LP4: the match-time tree is built in a throwaway
  "lazy" namespace without isSSR (not known at match time), so reusing it for
  render needs isSSR-dependent state split out (the LP4 refactor, touching
  shortCodes + the semantic matrix). Risk >> reward -> deferred as a documented
  decision, not planned work.

Changes:
- src/router/__tests__/lazy-include-cost.bench.ts (new): vitest bench measuring
  construct / match (LP3 waste) / render-cold / warm across 5 and 50-route
  non-leaf includes. Excluded from the default test run (.bench.ts).
- lazy-include-perf.test.ts: clarify the Part B "2" sentinel simulates the
  pre-C8 (shortcut-disabled) path and is expected to stay 2; rewrite the LP3
  it.todo with the measured numbers + deferral rationale.
- docs/internal/matching-stability-review.md: LP3 section rewritten as
  MEASURED/DEFERRED with the numbers and the LP4 entanglement; Follow-up updated
  (benchmark done; LP4 is the gate for revisiting LP3).
@pkg-pr-new

pkg-pr-new Bot commented Jun 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@545

commit: 0741f08

@ivogt
ivogt merged commit 6b33db2 into main Jun 8, 2026
25 checks passed
@ivogt
ivogt deleted the perf/router-lazy-include-benchmark branch June 8, 2026 08:02
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.

1 participant