NextRush
Concepts

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.

Loading diagram...

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 middleware

Response 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 header

Plugin 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:

  1. extendContext(ctx) — add custom properties or methods to the context
  2. onRequest(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 exits

Calling 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:

  1. Static routes — O(1) hash map lookup ("GET /users" → handler)
  2. 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:

  1. Execute guards (class-level, then method-level)
  2. Resolve controller instance from DI container
  3. Extract parameters using decorators (@Body, @Param, etc.)
  4. Call the controller method
  5. 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:

  1. Plugin onError(err, ctx) hooks run (each isolated — one failure does not block others)
  2. Your custom error handler runs (if set via setErrorHandler)
  3. 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

StageComplexityNotes
Context creationO(1)Object allocation + URL parsing
Plugin hooksO(p)p = number of plugins with hooks
Middleware executionO(n)n = number of middleware
Static route matchO(1)Hash map lookup
Dynamic route matchO(d)d = number of path segments
JSON serializationO(s)s = response data size

Next Steps

Now that you understand the request lifecycle:

On this page