Request Lifecycle
Follow one request through NextRush — adapter, context, the middleware onion, the router, your handler, and back out — so you always know where a concern belongs.
A request arrives as bytes on a socket and leaves as bytes on a socket. Everything your application does happens in the space between those two moments. When that space is a mystery — why is ctx.body empty, why did auth not run, why does a header set after ctx.next() behave differently — every bug turns into a hunt through unfamiliar territory. Learn the path once and you always know where to look.
What you'll learn
- Understand the ordered stages a request passes through, from the adapter to your handler and back
- Recognize that the router is itself a middleware, not a separate phase bolted onto the pipeline
- Explain why work registered after the router never runs, and why a body parser must run before the handler
- Choose which stage a concern belongs in — middleware, handler, or the error boundary
The problem
The tempting mental model is that a request "hits my handler." That picture is missing most of the story:
Reality, when the simple picture breaks down:
request → ??? → handler → ??? → response
│ │
│ └─ who set Content-Length? who serialized my object?
└─ where did ctx.body get parsed? did auth run? in what order?Everything in those ??? gaps — parsing, authentication, logging, route matching, response serialization — happens around your handler, and it is invisible if you only think in terms of the handler. So when the body is empty, or a guard never fired, or two responses collided, you have no map that tells you which stage to inspect. Guessing scales badly: the more middleware and routes you add, the more places a problem could be hiding.
Why this matters
This path runs on every single request your server ever handles — it is the hottest code in your application. A concern placed in the wrong stage doesn't merely read awkwardly; it can silently fail. Auth registered after the router protects nothing. A response method called after the pipeline already responded throws. Because the cost of a misplaced stage is a security hole or a production error rather than a compile error, holding an accurate model of the lifecycle is what keeps those mistakes from ever reaching a review.
The solution
NextRush runs every request through one composed pipeline of async middleware, and the router is the last middleware in it. There is no separate lifecycle-hook system, no per-plugin phase collection — a single onion of middleware wraps your handler, and an error boundary wraps the whole onion. The adapter builds a Context at the front, the composed pipeline runs, and the adapter serializes the response at the back. That is the entire journey.
Core idea
Think of the lifecycle as one request travelling inward through a stack of layers to reach your handler, then travelling back out through the same layers in reverse. The adapter is the doorway on both sides: it translates the platform's native request into ctx on the way in, and ctx back into a native response on the way out. Between those doorways sits the middleware onion — and the router is its innermost layer, the one that finally chooses and runs your handler.
Mental model
Don't count the arrows — notice the stacked activation bars. The middleware layer stays alive across the entire request because it is the outermost thing running, the router lives inside it, and the handler is deepest. The router isn't a stage after the middleware; it is the innermost middleware, which is why anything registered after it never gets a turn.
Quick example
The smallest app that makes the round trip visible: one middleware brackets the request, and one handler sits at the bottom of the onion.
import { createApp, createRouter, listen } from 'nextrush';
const app = createApp();
// Outermost layer: runs first on the way in, last on the way out.
app.use(async (ctx) => {
const start = Date.now();
await ctx.next(); // hand control down the pipeline
ctx.set('X-Response-Time', `${Date.now() - start}ms`); // runs after the handler responds
});
const router = createRouter();
router.get('/users/:id', (ctx) => {
ctx.json({ id: ctx.params.id }); // the handler, reached last, builds the response
});
app.route('/', router);
listen(app, 8080);The timing header is set after await ctx.next(), which is only possible because the middleware wraps everything downstream — including the router and handler — and finishes last.
How it works
Example — trace the order by logging on both sides of ctx.next():
import { createApp, createRouter, listen } from 'nextrush';
const app = createApp();
app.use(async (ctx) => {
console.log(`→ ${ctx.method} ${ctx.path}`); // in: middleware, before next()
await ctx.next(); // in: router matches, handler runs
console.log(`← ${ctx.status}`); // out: response built, after next()
});
const router = createRouter();
router.get('/hello', (ctx) => ctx.json({ ok: true }));
app.route('/', router);
listen(app, 8080);
// GET /hello logs: → GET /hello then ← 200Observation — the → line runs before the handler and the ← line runs after, from the same function. Nothing about the handler had to change for the middleware to bracket it.
Explanation — five things happen in order, and each maps to a participant in the diagram:
- The adapter builds the context. The platform hands the adapter a native request; the adapter constructs one
ctx, parsing method, path, query, headers, and client IP up front. How that construction differs per runtime is the adapter's job — your code sees the samectxeverywhere. - The composed pipeline runs.
createAppfolds every registered middleware into a single function withcompose(). Each layer runs its "before" code, callsctx.next(), and — after everything downstream settles — runs its "after" code. That inversion is the onion model. - The router matches, as the innermost middleware. When you register routes with
app.get()/app.post(), the app-owned router is mounted last, so it sees a fully assembledctx. On a match it setsctx.paramsand runs your handler; on a miss it setsctx.status = 404and callsnext(). The matching strategy — static lookup then segment-trie traversal — lives in the routing concept. - The handler produces the response. It reads
ctx.params/ctx.bodyand sends throughctx.json(),ctx.send(), orctx.html(). The fullctxsurface is the Context concept — a lifecycle page teaches when each part is ready, not every member. - The adapter writes the response. After the onion unwinds, the adapter serializes
ctxback into a native response. If nothing in the pipeline responded, it sends a fallback:{ "error": "Not Found" }for a 404, otherwise an empty body with the currentctx.status.
Any error thrown at any stage skips the rest and lands in one place — the boundary callback() wraps around the whole pipeline:
import { createApp, createRouter, listen, NotFoundError } from 'nextrush';
const app = createApp();
const router = createRouter();
router.get('/users/:id', (ctx) => {
// Thrown from a handler or any middleware — the app's error boundary catches it.
throw new NotFoundError(`No user ${ctx.params.id}`);
});
app.route('/', router);
listen(app, 8080);There is a single error path: your custom handler if you set one, otherwise the built-in handler that maps the error to a safe response. The internals of how compose() dispatches the stack belong to the middleware internals — a concept page teaches the model, not the machinery.
Typical use cases
Knowing the stages is mostly useful for putting a concern in the right one — and for narrowing down where a bug lives:
- Empty
ctx.body? The body is parsed by a body-parser middleware, which must run before the handler. No parser registered meansctx.bodyisundefinedwhen the handler reads it. - A guard that never fires? A security layer only guards what runs inside it. Registered after the router, it sits outside the handler entirely.
- A header that "won't set"? Setting a header after the response has already been sent is too late — check whether an earlier stage responded first.
- A generic 500 in production? The error boundary caught a throw and returned a safe response. Look at the stage that threw, not the response.
Performance
- Complexity — context creation is O(1) (one allocation plus URL parsing); the pipeline is O(n) in the number of middleware it passes through; static route matches are O(1) and dynamic ones O(k) in the number of path segments. Route count does not enter the cost.
- Memory — one
ctxis allocated per request and passed by reference through every layer; it is never deep-cloned. The composed pipeline function is built once at startup and shared across all requests. - Scaling — the per-request cost grows with the length of the middleware chain, so keep hot-path layers lean and capture configuration at startup rather than per request. Measure with
apps/benchmarkbefore optimizing.
Security
The lifecycle's ordering is a security boundary, and its edges are where untrusted data enters and where internal detail can leak out.
- Middleware order. Threat: authentication, CORS, or rate-limiting registered after the router never runs before a handler. Why: the router is the innermost middleware, so anything after it is outside the layers that reach your code. Safe default: register security middleware before your routes and the app-owned router. Avoid: adding a guard after the route it is meant to protect.
- Untrusted input at construction. Threat:
ctx.body,ctx.query,ctx.params, andctx.headersarrive attacker-controlled. Why: the adapter transports request data intoctxbut never validates it. Safe default: validate type, range, length, and format before use. Avoid: passing raw request data into a query, file path, or redirect. - Error leakage at the boundary. Threat: a custom error handler echoes
err.messageorerr.stackto the client. Why: raw error objects expose internal paths and structure. Safe default: the built-in handler returns a generic message in production; keep that behavior in any custom handler. Avoid: returning internal error detail from the error stage in production.
In production the built-in error handler returns a generic response on purpose, so internal paths and stack traces never reach the client. If you replace it with a custom error handler, preserve that — do not send err.message or err.stack to clients.
Trade-offs
Why one pipeline with the router as its last layer — it optimizes for a single model to learn: everything that touches a request is middleware, and one error boundary wraps them all.
- Benefits — one mental model instead of a catalogue of lifecycle hooks; a single place to catch every error; predictable, order-driven behavior; the same journey on every runtime because only the adapter edges differ.
- Costs — registration order becomes load-bearing, since "the router runs last" means a concern placed after it is dead code; the inward-then-outward onion takes a moment to internalize.
- Alternatives — a framework with named per-phase hooks (before-route, after-handler, and so on) makes each phase explicit but multiplies the concepts you must learn and the places behavior can hide.
- Why NextRush chose this — fewer moving parts. Composing one middleware stack and mounting the router within it means there is exactly one ordering rule to understand, and it composes cleanly across Node, Bun, Deno, and edge runtimes.
Decision guide
Put a concern in middleware when:
- ✓ It spans many routes — auth, logging, timing, CORS, body parsing
- ✓ It needs to act both before the handler and after the response is produced
Put it in the handler when:
- ✓ It is specific to one route and produces that route's response
Put it in the error boundary when:
- ✓ It shapes how failures become responses across the whole app — set it once with
setErrorHandler, don't repeat error formatting in every handler
Common mistakes
- Registering auth or a guard after the routes. Why it happens: middleware is added in the order features are built, not the order they must run. Correct approach: register outer concerns before your routes so they wrap the handler. If ignored: the guard sits outside the router and never runs for the routes it was meant to protect — a silent security hole.
- Reading
ctx.bodywith no body parser. Why it happens:ctx.bodylooks like it is always populated. Correct approach: add a body-parser middleware before the handler. If ignored:ctx.bodyisundefinedand the handler treats a real payload as empty. - Forgetting
awaitonctx.next(). Why it happens:next()returns a promise that is quick to overlook. Correct approach: alwaysawait ctx.next()when code must run after downstream finishes. If ignored: the "after" half runs before the response is ready, and the request can settle out from under you. - Sending two responses. Why it happens: two stages each call a send method on the same
ctx. Correct approach: respond exactly once per request. If ignored: the second call errors or is discarded depending on where it runs.
Key takeaways
- Every request follows one path: adapter builds
ctx→ middleware onion runs → router matches → handler responds → adapter writes the response. - The router is the innermost middleware, mounted last — anything registered after it never runs.
- Middleware brackets the handler: "before" code runs in registration order, "after" code runs in reverse.
ctx.bodyis populated by a body-parser middleware that must run before the handler; it isundefinedotherwise.- Errors thrown at any stage land in a single boundary, which returns a safe, generic response in production.
- Where a concern belongs — middleware, handler, or error boundary — follows directly from where it needs to run in this path.
Continue learning
Middleware
The onion model in depth — the layers the request travels through to reach your handler.
Write custom middleware
Put a stage to work — read input, share state, and bracket the request in your own middleware.
@nextrush/core reference
The Application, compose(), and Context surface — signatures and options.
Adapters
How each runtime's adapter builds ctx at the front of the path and serializes it back at the end.
Application Lifecycle
OnInit and OnShutdown — duck-typed hooks that run once at boot and once at shutdown, distinct from the per-request lifecycle that runs on every request.
Runtime Compatibility
Why NextRush treats the Web Platform as the foundation and every runtime as an adapter, so the same application code runs on Node.js, Bun, Deno, and the edge with no branching.