Request Lifecycle
Follow a request through NextRush from arrival to response — understanding the complete execution flow.
Understanding how requests flow through NextRush helps you write better middleware and debug issues faster.
The Complete Flow
Every HTTP request passes through six stages. Plugin hooks wrap the middleware chain, and the router is itself a middleware — not a separate layer.
Stage by Stage
Adapter Creates Context
The adapter receives the raw HTTP request and creates a Context object. In the Node.js adapter, this happens inside createHandler:
// Simplified from @nextrush/adapter-node
const handler = app.callback();
return (req, res) => {
const ctx = createNodeContext(req, res, { trustProxy });
handler(ctx).then(
() => {
// Ensure response is sent if handler didn't respond
if (!ctx.responded && !res.headersSent) {
res.statusCode = ctx.status === 404 ? 404 : ctx.status;
res.end();
}
},
(error) => {
// Last-resort error handling
if (!res.headersSent) {
res.statusCode = 500;
res.end(JSON.stringify({ error: 'Internal Server Error' }));
}
}
);
};The Context object wraps the raw request and response. It parses the URL, method, headers, query string, and client IP at construction time.
Request properties are read-only and available immediately:
ctx.method; // 'GET', 'POST', etc.
ctx.url; // '/users/123?page=1'
ctx.path; // '/users/123'
ctx.query; // { page: '1' }
ctx.headers; // { 'content-type': 'application/json', ... }
ctx.ip; // Client IP (respects trustProxy option)Mutable state is populated by middleware and the router during the request lifecycle:
ctx.status; // Initially 200
ctx.body; // Request body — undefined until body-parser middleware runs
ctx.params; // Route params — empty until router sets them
ctx.state; // {} — shared state bag for middlewareResponse methods send data back to the client:
ctx.json(data); // Send JSON response
ctx.send(data); // Send text, buffer, or stream
ctx.html(str); // Send HTML
ctx.redirect(url); // Redirect (302 by default)
ctx.set(k, v); // Set response headerPlugin Hooks Run
Before the middleware chain executes, app.callback() runs plugin lifecycle hooks on every request. Plugins that implement PluginWithHooks are collected once at build time.
For each plugin, in installation order:
extendContext(ctx)— add custom properties or methods to the contextonRequest(ctx)— run pre-middleware logic (logging, tracing, etc.)
// Example: A plugin that adds request timing
const timingPlugin: PluginWithHooks = {
name: 'timing',
install() {},
extendContext(ctx) {
(ctx.state as Record<string, unknown>).startTime = Date.now();
},
onRequest(ctx) {
// Runs before middleware chain
},
onResponse(ctx) {
// Runs after middleware chain completes
},
};After the middleware chain completes, onResponse(ctx) runs for each plugin. Each onResponse hook is isolated — if one throws, the error is logged and remaining hooks still execute.
Middleware Chain Executes
The middleware stack is composed into a single function via compose(). Each middleware receives (ctx, next) and can run code before and after calling next().
app.use(async (ctx) => {
const start = Date.now(); // BEFORE
await ctx.next(); // Continue to next middleware
const ms = Date.now() - start; // AFTER (all downstream complete)
ctx.set('X-Response-Time', `${ms}ms`);
});Middleware executes like an onion — each layer wraps the next:
Request enters →
┌───────────────────────────────────────┐
│ Middleware 1 (before) │
│ ┌───────────────────────────────┐ │
│ │ Middleware 2 (before) │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ Router middleware │ │ │
│ │ │ ┌─────────────────┐ │ │ │
│ │ │ │ Handler │ │ │ │
│ │ │ └─────────────────┘ │ │ │
│ │ │ (router returns) │ │ │
│ │ └───────────────────────┘ │ │
│ │ Middleware 2 (after) │ │
│ └───────────────────────────────┘ │
│ Middleware 1 (after) │
└───────────────────────────────────────┘
← Response exitsCalling next() more than once in a single middleware throws an error. The compose() function
tracks dispatch index per request and rejects duplicate calls.
Router Matches the Route
The router is a middleware in the stack. When it executes, it matches the request path against registered routes using a two-tier strategy:
- Static routes — O(1) hash map lookup (
"GET /users"→ handler) - Dynamic routes — O(d) segment trie traversal, where d is the number of path segments
const router = createRouter();
router.get('/users', listUsers); // Static: O(1) lookup
router.get('/users/:id', getUser); // Dynamic: trie traversal
router.get('/files/*', serveFile); // Wildcard: catches rest
app.route('/', router);When a route matches, the router sets ctx.params and calls the pre-compiled handler executor. When no route matches, it sets ctx.status = 404 and calls next() to let downstream middleware handle it.
// GET /users/123 matches router.get('/users/:id', getUser)
// ctx.params → { id: '123' }When you mount a router with a prefix via app.route('/api', router), the prefix is stripped from ctx.path before the router sees it, then restored after.
Handler Runs
The matched handler runs with the populated context:
async function getUser(ctx) {
const { id } = ctx.params;
const user = await db.users.findById(id);
if (!user) {
ctx.status = 404;
ctx.json({ error: 'User not found' });
return;
}
ctx.json(user);
}For class-based controllers (via @nextrush/controllers), the handler building pipeline adds steps before calling your method:
- Execute guards (class-level, then method-level)
- Resolve controller instance from DI container
- Extract parameters using decorators (
@Body,@Param, etc.) - Call the controller method
- Serialize the return value as JSON
Response Finalization
After the handler completes and the middleware chain unwinds, the adapter ensures a response is sent. If your handler or middleware already called a response method (ctx.json(), ctx.send(), etc.), the context is marked as responded and no further action is taken.
If nothing responded, the adapter sends a fallback:
- Status 404: sends
{ "error": "Not Found" } - Other status: ends the response with the current
ctx.status
Error Handling
Errors thrown at any stage are caught by app.callback(). The error flow is:
- Plugin
onError(err, ctx)hooks run (each isolated — one failure does not block others) - Your custom error handler runs (if set via
setErrorHandler) - If no custom handler, or the custom handler throws, the default handler responds
// Set a custom error handler
app.setErrorHandler((error, ctx) => {
if ('status' in error && typeof error.status === 'number') {
ctx.status = error.status;
ctx.json({ error: error.message });
} else {
ctx.status = 500;
ctx.json({ error: 'Internal Server Error' });
}
});You can throw errors from anywhere in the middleware chain:
// HTTP error with status
ctx.throw(404, 'User not found');
// Error classes from @nextrush/errors
import { NotFoundError } from '@nextrush/errors';
throw new NotFoundError('User not found');
// Plain errors become 500
throw new Error('Database connection failed');In production, the default error handler returns a generic "Internal Server Error" to avoid leaking internal details. Set a custom error handler to control error responses.
Performance Characteristics
| Stage | Complexity | Notes |
|---|---|---|
| Context creation | O(1) | Object allocation + URL parsing |
| Plugin hooks | O(p) | p = number of plugins with hooks |
| Middleware execution | O(n) | n = number of middleware |
| Static route match | O(1) | Hash map lookup |
| Dynamic route match | O(d) | d = number of path segments |
| JSON serialization | O(s) | s = response data size |
Next Steps
Now that you understand the request lifecycle:
- Learn how middleware composition works in detail
- See how NextRush achieves runtime compatibility
- Explore the package hierarchy