Skip to content

perf(app-router): cache route-handler dispatch import#2501

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/app-route-handler-hot-path
Open

perf(app-router): cache route-handler dispatch import#2501
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/app-route-handler-hot-path

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Overview

Item Detail
Goal Reduce steady-state Worker overhead for App Router route handlers.
Core change Cache the resolved dispatchAppRouteHandler function after the generated RSC entry's first lazy import.
Boundary Route-handler dispatch stays lazy and split from the main Worker entry.
Primary files packages/vinext/src/entries/app-rsc-entry.ts, tests/entry-templates.test.ts
Benchmark signal /api/hello median avg 2.5881ms -> 2.4354ms (-0.1527ms/request, about -5.9%); p90 avg 3.4362ms -> 2.9384ms.
Bundle impact Main Worker entry remains 180,972 bytes (52,281 gzip) and route dispatch remains a 13,716 byte split chunk.

Why This Is Still A Win

Route handlers are steady-state Worker hot-path requests. The first-principles target is simple: keep cold or optional route-handler runtime out of the main Worker entry, but do not keep paying route-handler module-resolution work after the isolate already loaded that module.

Area Principle / invariant What this PR changes
Per-request work Work paid on every request compounds directly into route latency. Later requests reuse the already resolved dispatch function instead of re-entering the dynamic import loader path.
Per-isolate work Work that is only needed for route handlers can be paid once when the isolate first handles a route. The first route request still imports app-route-handler-dispatch; the cache only applies after that.
Main Worker bundle A Workers perf win should not buy a tiny route gain by making every entry request load more JS. The dispatch runtime remains split out instead of becoming a static import.
App Router semantics The generated entry should not duplicate route dispatch behavior. Dispatch still executes through app-route-handler-dispatch; this only changes how the generated entry reaches it after first load.

This matters even though the median delta is small. The static-import experiment made the route path faster, but it moved dispatch code into the main entry (222.63 kB, 64.90 kB gzip), which makes unrelated Worker traffic pay for route-handler runtime. This version keeps the main entry at 180,972 bytes (52,281 gzip) and removes repeat steady-state overhead only for isolates that actually serve route handlers.

Worker Benchmark

Same fixture and local Worker runtime as the investigation path: tests/fixtures/cf-app-basic, Wrangler local workerd, /api/hello, 300 warmup requests, 2000 measured requests, 4 rounds.

Metric Same-session baseline This PR Delta
Median avg 2.5881ms 2.4354ms -0.1527ms/request (-5.9%)
p90 avg 3.4362ms 2.9384ms -0.4978ms/request
Median rounds 2.5727, 2.4550, 2.6480, 2.6766 2.5379, 2.3263, 2.4365, 2.4407 Lower in all paired rounds.
p90 rounds 3.9219, 3.0033, 3.4633, 3.3562 3.2933, 2.7144, 2.9570, 2.7887 Lower in all paired rounds.

Bundle shape after the change:

Build artifact Size
dist/server/index.js 180,972 bytes
gzipped dist/server/index.js 52,281 bytes
route dispatch chunk 13,716 bytes

What Changed

Scenario Before After
First route-handler request in an isolate Dynamically imports app-route-handler-dispatch. Still dynamically imports app-route-handler-dispatch.
Later route-handler requests in the same isolate Awaits the loader path again. Reuses the cached dispatch function directly.
Page and server-action paths Route-handler runtime stays deferred. Same.
Maintainer review path
  1. packages/vinext/src/entries/app-rsc-entry.ts

    • Review the generated loader shape.
    • Confirm it still uses dynamic import() and only caches the resolved function.
  2. tests/entry-templates.test.ts

    • Confirms the route-handler runtime is still deferred.
    • Confirms no static dispatchAppRouteHandler import is emitted.
Validation

Checks run:

vp test run tests/entry-templates.test.ts tests/app-router-isr-codegen.test.ts tests/app-router-next-config-codegen.test.ts tests/app-rsc-handler.test.ts tests/app-route-handler-dispatch.test.ts tests/app-route-handler-execution.test.ts tests/after-deploy.test.ts
vp check packages/vinext/src/entries/app-rsc-entry.ts tests/entry-templates.test.ts
vp run vinext#build
cd tests/fixtures/cf-app-basic && vp build

Worker benchmark:

cd tests/fixtures/cf-app-basic
./node_modules/.bin/wrangler dev --config dist/server/wrangler.json --port 4192 --log-level error

Then /api/hello, 300 warmup, 2000 measured requests, 4 rounds.

Risk / compatibility
  • Public API: no public API change.
  • Runtime behavior: route dispatch still executes through the existing dispatch helper.
  • Build output: route dispatch remains lazy and split.
  • Worker impact: small steady-state route-handler improvement without the large main-bundle regression seen with a static import.
  • Existing apps: first request in an isolate still pays the dynamic import as before.
Non-goals
  • This does not implement a route-handler-specific request-state fast path.
  • This does not change cookies, headers, middleware, fetch cache, ISR, route matching, or response finalisation.
  • This does not optimise server-action dispatch.

Route handler requests currently await the generated RSC entry's dynamic import of the dispatch runtime on every request, even after the module has loaded. That keeps an import/promise boundary in the steady-state App Route path.

Cache the resolved dispatch function after the first import and call it directly on later route-handler dispatches while keeping the dispatch runtime in its lazy chunk. This preserves page and server-action deferral and avoids the bundle-size regression from a static import.
@pkg-pr-new

pkg-pr-new Bot commented Jul 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2501
npm i https://pkg.pr.new/vinext@2501

commit: b531eee

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review July 3, 2026 10:44
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b531eee against base 17216d6 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.4 KB 126.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 120.5 KB 120.5 KB ⚫ +0.0%
Dev server cold start vinext 2.57 s 2.55 s ⚫ -0.7%
Production build time vinext 3.23 s 3.24 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 98.0 KB 98.1 KB ⚫ +0.0%
Server bundle size (gzip) vinext 164.5 KB 164.5 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@TheAlexLichter if you don't mind me asking a noob question, PRs like this where the Ws are quite small aren't really worth it right? I've been trying to optimise the warm SSR path so Vinext performs better on Node (not really a priority, I just wanted to see if we can do better than Next here) but I can't really find anything.

CleanShot 2026-07-03 at 23 09 19@2x

@TheAlexLichter

Copy link
Copy Markdown
Collaborator

It depends!

As we dont bench all different "types of apps"/cases, as long as you can justify the change that will improve a certain type of apps this should be fine.
Same for other metrics that we don't bench (like chunk allocation for example).

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