Skip to content

perf(middleware): precompose middleware chains#1475

Merged
pi0 merged 4 commits into
mainfrom
perf/middleware
Jul 17, 2026
Merged

perf(middleware): precompose middleware chains#1475
pi0 merged 4 commits into
mainfrom
perf/middleware

Conversation

@pi0x

@pi0x pi0x commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

🔗 Linked issue

none

📚 Description

Middleware dispatch currently pays a per-request cost that only depends on registration-time state: ~getMiddleware re-builds the middleware array on every request (an array spread whenever the route has its own middleware), and callMiddleware walks it recursively with index bookkeeping through a single megamorphic fn(event, next) call site.

Since both the global middleware list and each route's middleware are fixed between registrations, this PR precomposes the chains once and caches them:

  • composeMiddleware(middleware) builds the chain right-to-left into a single (event, handler) => result function. Each layer is its own closure, so middleware call sites become monomorphic. The terminal handler is threaded through as an argument (rather than baked in or read from event.context.matchedRoute, which a nested app can overwrite mid-chain), so one composed global chain serves every route.
  • Per-route cache: route middleware + handler are composed on first match and cached on the route object (~composed). Only route-local state is baked in, so the cache stays valid when mount copies route objects.
  • Per-app cache: the composed global chain is cached on the app (~composed) and explicitly invalidated by use() and mount(), so middleware added after the first request is picked up. The mount wrapper reuses the mounted app's own ~composed cache, so the child's use() invalidates it too.
  • defineHandler({ middleware }) now composes once at definition time instead of re-dispatching per call.

Public API

composeMiddleware (and the ComposedMiddleware type) is exported from h3 so downstream frameworks (e.g. nitro) can adopt precomposition for their own middleware chains instead of callMiddleware. callMiddleware remains exported and unchanged.

Backward compatibility (~getMiddleware)

Existing users extending H3Core (e.g. nitro) override ~getMiddleware — including as an instance-level assignment — to provide per-event middleware, which cannot be precomposed. The dispatch strategy is resolved once per app (~dispatch, built on first request and invalidated by use()/mount()): if ~getMiddleware is overridden (method identity check, run only at dispatcher build time — no per-request cost), dispatch falls back to the previous callMiddleware behavior unchanged. Overrides must be in place before the first request (nitro assigns at build time). A regression test covers the nitro pattern.

next() memoization semantics (undefined/kNotFound fall-through, thenable handling) are preserved byte-for-byte; the shared per-layer logic lives in one callLayer helper used by both the composed chain and the public callMiddleware.

Numbers

Isolated dispatch bench (mitata, node 24):

depth vs callMiddleware vs spread + callMiddleware allocations
3 1.09x faster 1.13x faster −25%
10 1.13x faster 1.18x faster −21%

App-level pnpm bench:node is unchanged (dispatch is sub-µs of a ~31µs iteration dominated by Request/URL work); the win scales with middleware depth and route-middleware usage.

Bundle cost of shipping both the composer and the compat path: H3 +599 B (+191 B gzip), H3Core +539 B (+183 B gzip), defineHandler +103 B (+50 B gzip); budgets in bundle.test.ts bumped to match (kept tight).

Known edge (outside documented API): mutating a middleware array in place (a route's middleware after its first match, or ~middleware directly without use()) is not picked up live — invalidation is explicit via use()/mount(). Custom ~getMiddleware overrides are unaffected.

Possible follow-up: in the h3-middleware bench scenario the remaining overhead is dominated by normalizeMiddleware's per-request route regexp matching and the middlewareParams spread — baking matchers into composition would be the next lever.


🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added middleware composition utilities (composeMiddleware / composeHandler) and re-exported ComposedMiddleware.
  • Performance Improvements
    • Reduced per-request middleware/handler overhead by caching precomposed route pipelines and composing mounted middleware once.
    • Updated handler middleware wrapping to use the new composition model.
  • Compatibility
    • Preserved dynamic, per-event middleware behavior when overriding middleware resolution, with proper cache invalidation after use() and mount().
  • Tests
    • Added coverage for composition invalidation and dynamic middleware overrides.
    • Tightened bundle size benchmark limits and updated export snapshots.

@pi0x
pi0x requested a review from pi0 as a code owner July 17, 2026 09:43
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The middleware system now supports precomposed execution chains. H3Core caches global, route, and mounted middleware pipelines while preserving dynamic behavior for overridden ~getMiddleware implementations. Tests cover compatibility, cache invalidation, exports, and bundle-size limits.

Middleware composition

Layer / File(s) Summary
Precomposed middleware primitives
src/middleware.ts, src/handler.ts, src/index.ts, test/unit/package.test.ts
Adds reusable composition helpers and the ComposedMiddleware type; defineHandler and package exports use the new APIs.
H3Core pipeline caching
src/h3.ts, src/types/h3.ts
Caches route, global, and mounted middleware compositions, adds cache fields, and documents ~getMiddleware override behavior.
Compatibility and size validation
test/unit/middleware.test.ts, test/bench/bundle.test.ts
Adds coverage for dynamic overrides and cache invalidation, and lowers bundle-size thresholds.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: pi0, pi0

Poem

A rabbit hops through middleware streams,
Composing chains from cached-up dreams.
Routes remember each leafy turn,
Dynamic paths still twist and learn.
Thump, thump, shipped just right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: middleware chains are precomposed for performance.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/middleware

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/unit/middleware.test.ts`:
- Around line 68-94: Update the "~getMiddleware compat" test suite to use
describeMatrix so it runs in both web and node runtimes. Adapt the test setup to
the matrix context, creating the H3 app per test via beforeEach if needed, and
use ctx.fetch instead of app.request when required by the existing cross-runtime
test conventions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a7d67c01-2701-48df-8194-f0a843ffc54c

📥 Commits

Reviewing files that changed from the base of the PR and between 879c869 and b106195.

📒 Files selected for processing (6)
  • src/h3.ts
  • src/handler.ts
  • src/middleware.ts
  • src/types/h3.ts
  • test/bench/bundle.test.ts
  • test/unit/middleware.test.ts

Comment on lines +68 to +94

describe("~getMiddleware compat", () => {
test("instance-level override provides per-event middleware (nitro pattern)", async () => {
const app = new H3().get("/test", (event) => `handler:${event.context.order}`);
const push = (name: string): Middleware => {
return (event, next) => {
event.context.order = `${event.context.order || ""}+${name}`;
return next();
};
};
app.use(push("global"));
// Nitro assigns an instance-level override returning a per-event array:
// https://github.com/nitrojs/nitro/blob/main/src/build/virtual/app.ts
app["~getMiddleware"] = (event, route) => {
const middleware = [...app["~middleware"]];
if (event.url.pathname === "/test" && route) {
middleware.push(push("extra"));
}
return middleware;
};
// Repeated requests: override must run per event (no stale precomposition)
for (let i = 0; i < 2; i++) {
const res = await app.request("/test");
expect(await res.text()).toBe("handler:+global+extra");
}
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use describeMatrix for tests instead of describe.

As per coding guidelines, tests should use describeMatrix to ensure they execute across both web and node runtimes. Additionally, consider whether the app instance should be set up via a beforeEach hook and whether to use ctx.fetch instead of app.request to fully validate cross-runtime behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/middleware.test.ts` around lines 68 - 94, Update the
"~getMiddleware compat" test suite to use describeMatrix so it runs in both web
and node runtimes. Adapt the test setup to the matrix context, creating the H3
app per test via beforeEach if needed, and use ctx.fetch instead of app.request
when required by the existing cross-runtime test conventions.

Source: Coding guidelines

pi0 and others added 3 commits July 17, 2026 09:48
Resolve the custom `~getMiddleware` check once when building the cached
dispatcher instead of on every handler call.

Co-Authored-By: Claude Fable 5 <[email protected]>
@pi0x

pi0x commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up for downstream adoption: nitrojs/nitro#4443 (drop the ~getMiddleware override in favor of composeMiddleware + h3's precomposed fast path).

@pi0
pi0 merged commit 75ac47d into main Jul 17, 2026
9 checks passed
@pi0
pi0 deleted the perf/middleware branch July 17, 2026 10:32
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