Hi, Devlin. Ten years of fullstack, currently in Norway, currently re-reading my own "why is this middleware running twice" Slack threads from 2018, 2021, and 2024 — every framework, same question, same shrug. So here is the post I wish someone had pinned in the channel: every DaloyJS lifecycle hook, in order, with a one-sentence rule for what belongs in each one.
The good news: there are six hooks. The better news: they fire in the order they appear in the code, in the order you registered them, in three nested scopes (global → group → route). No adapter shim, no "extends" chain, no hidden re-entry. You read the file, you know what happens.
The whole API in one screen
// @daloyjs/core — the entire Hooks interface, no surprises.
export interface Hooks {
onRequest?: (req: Request) => void | Promise<void>;
beforeHandle?: (ctx: BaseContext) => void | Response | Promise<void | Response>;
afterHandle?: (ctx: BaseContext, result: unknown) => void | unknown | Promise<void | unknown>;
onSend?: (res: Response, ctx?: BaseContext) => void | Response | Promise<void | Response>;
onResponse?: (res: Response) => void | Promise<void>;
onError?: (err: unknown, ctx?: BaseContext) => void | Response | Promise<void | Response>;
}
//
// Successful request order:
// onRequest → beforeHandle → handler → afterHandle → onSend → onResponse
// Error path:
// onRequest → (anywhere it throws) → onError → onSend → onResponse
six hooks · two phases · zero hidden onesTS · UTF-8 · LF
The lifecycle, drawn
time ─────────────────────────────────────────────────────────────────▶
┌──────────┐ ┌──────────────┐ ┌─────────┐ ┌─────────────┐
│onRequest │ → │ beforeHandle │ → │ handler │ → │ afterHandle │ → …
└──────────┘ └──────────────┘ └─────────┘ └─────────────┘
▲ │ may return │ may return
│ ▼ a Response (short-circuit) ▼ a new value
│
raw Request ┌────────┐
│ ┌────────────┐ ┌────────────┐ │ socket │
│ … → │ onSend │ → │ onResponse │→ │ closes │
│ └────────────┘ └────────────┘ └────────┘
│ │ may fire-and-forget
│ ▼ replace observer
│
└──[ anywhere throws ]──▶ onError ─▶ Response ─▶ onSend ─▶ onResponse
hot path top · error path bottom · happens in one tickTS · UTF-8 · LF
Each hook, with the one-line rule
1onRequest(req: Request) => void | Promise<void>
Fires before any context is built. You see the raw Request. Use this for things that need the untouched body or headers — TLS hints, conditional decode of the raw byte stream. Almost everything else belongs in beforeHandle.
can return → nothing (observer over the raw Request)
2beforeHandle(ctx) => void | Response
The single most important hook. Authentication, authorization, rate limiting, maintenance gates, feature flags. Return a Response here and the handler never runs. Throw an HttpError here and the framework turns it into RFC 9457 problem+json for you.
can return → a Response to short-circuit the handler
3handler(ctx) => { status; body; headers? }
Your code. Nothing else lives here. If you find yourself writing "middleware-ish" logic at the top of a handler, that's a sign it wants to be a hook.
can return → the result, always
4afterHandle(ctx, result) => void | unknown
Transform the handler's return value before the framework serializes and validates it. Use sparingly. Reach for it when the transformation spans many routes — global PII redaction, envelope wrapping. For a single endpoint, just shape the body in the handler.
can return → a transformed result
5onSend(res: Response, ctx?) => void | Response
Fires after the Response is built — on every response (success, error, OPTIONS preflight). This is the right place to stamp universal headers: X-Request-Id, Server-Timing, security headers. Mutate res.headers in place, or return a brand-new Response to replace it entirely.
can return → a replacement Response (or mutate headers in place)
6onResponse(res: Response) => void | Promise<void>
Fire-and-forget observer. By design, it cannot change the response — the bytes are already on the wire. This is your logging, metrics, and audit-event slot. Safe to put slow stuff here (within reason); it won't block the client.
can return → nothing (the response already left)
!onError(err, ctx?) => void | Response
Runs on the error path before serialization. Log once, translate library-foreign errors into your own HttpError subclasses, then fall through to the default problem+json serializer (which already does production redaction — see the RFC 9457 errors post).
can return → a Response to take over rendering
Three scopes, composed
Each kind of hook can be registered at three levels. They compose pipeline-style: global runs first, then group, then route. Same hook kind at the same level composes in registration order. That's it. There is no "priority" field.
// Three scopes. Composed in this order. No magic.
const app = new App({
hooks: { /* (1) GLOBAL — runs first, every request, every route */ },
});
app.use({ /* (2) GROUP — runs after global, on routes registered AFTER this */ });
app.route({
method: "GET",
path: "/admin",
handler: ...,
hooks: { /* (3) ROUTE — runs last, only on THIS route */ },
});
// Each hook *kind* (onRequest, beforeHandle, etc.) composes pipeline-style:
// global onSend, then every group onSend in registration order, then route onSend.
// First one to return a new Response wins the next stage's input.
global → group (app.use) → route (route.hooks)TS · UTF-8 · LF
Watch the order in your terminal
If you don't internalize one other thing from this post, internalize this snippet. It's the fastest way to lock the ordering into your fingers:
scripts/lifecycle-demo.ts
// Watch the order. The console output is the easiest way to internalize it.
const app = new App({
hooks: {
onRequest: () => console.log("[1] global onRequest"),
beforeHandle: () => console.log("[2] global beforeHandle"),
afterHandle: () => console.log("[6] global afterHandle"),
onSend: () => console.log("[8] global onSend"),
onResponse: () => console.log("[10] global onResponse"),
},
});
app.use({
beforeHandle: () => console.log("[3] group beforeHandle"),
afterHandle: () => console.log("[7] group afterHandle"),
onSend: () => console.log("[9] group onSend"),
});
app.route({
method: "GET",
path: "/x",
handler: async () => {
console.log("[5] handler runs");
return { status: 200, body: { ok: true } };
},
hooks: {
beforeHandle: () => console.log("[4] route beforeHandle"),
},
});
// $ curl http://localhost:3000/x
// [1] global onRequest
// [2] global beforeHandle
// [3] group beforeHandle
// [4] route beforeHandle
// [5] handler runs
// [6] global afterHandle
// [7] group afterHandle
// [8] global onSend
// [9] group onSend
// [10] global onResponse
run it · read the numbers · keep it in muscle memoryTS · UTF-8 · LF
Recipe 1 — Short-circuit with beforeHandle
The maintenance-window pattern, written once, applied globally, with an escape hatch for the liveness probe so your orchestrator doesn't kill the pod while you're fixing things:
src/middleware/maintenance.ts
// src/middleware/maintenance.ts — short-circuit BEFORE the handler runs.
import type { Hooks } from "@daloyjs/core";
export function maintenanceMode(opts: { enabled: () => boolean }): Hooks {
return {
beforeHandle(ctx) {
if (!opts.enabled()) return; // ← void: continue the pipeline
if (ctx.route?.path === "/healthz") return; // ← let liveness through
return new Response(
JSON.stringify({
type: "https://daloyjs.dev/errors/service-unavailable",
title: "Maintenance in progress",
status: 503,
detail: "Back in roughly 5 minutes. Thank you for your patience.",
}),
{
status: 503,
headers: {
"content-type": "application/problem+json",
"retry-after": "300",
},
},
);
},
};
}
// Globally:
app.use(maintenanceMode({ enabled: () => process.env.MAINTENANCE === "1" }));
return a Response → handler never runs → RFC 9457 503 + Retry-AfterTS · UTF-8 · LF
src/middleware/require-role.ts
// src/middleware/require-role.ts — a per-route auth gate.
// beforeHandle is where authentication and authorization live, because
// short-circuiting here means the handler never runs, never queries the DB,
// never consumes the body, never burns its rate-limit slot.
export function requireRole(role: string): Hooks {
return {
beforeHandle(ctx) {
const user = ctx.state.session?.user;
if (!user) throw new UnauthorizedError("Sign in first");
if (!user.roles?.includes(role))
throw new ForbiddenError(`Need role: ${role}`);
},
};
}
app.route({
method: "DELETE",
path: "/admin/users/:id",
handler: deleteUser,
hooks: { ...requireRole("admin") }, // ← scoped to THIS route only
});
throw HttpError → framework serializes problem+json for freeTS · UTF-8 · LF
Recipe 2 — Stamp headers with onSend
Server-Timing on every response, including errors and preflights. Notice the pattern: stash the start time in ctx.state from beforeHandle, read it from onSend. Mutate the response headers in place — no need to return a new Response:
src/middleware/server-timing.ts
// onSend — the right place to stamp response headers on EVERY response,
// including the ones produced by error paths and OPTIONS preflights.
export function stampServerTiming(): Hooks {
return {
beforeHandle(ctx) {
ctx.state._startedAt = performance.now();
},
onSend(res, ctx) {
// Mutate in place — no need to return a new Response.
const elapsed = performance.now() - (ctx?.state._startedAt ?? 0);
res.headers.set("server-timing", `app;dur=${elapsed.toFixed(1)}`);
res.headers.set("x-request-id", ctx?.requestId ?? "unknown");
},
};
}
app.use(stampServerTiming());
// You can also REPLACE the response by returning a new one:
// onSend(res) {
// if (res.status !== 401) return;
// return new Response(res.body, {
// status: 401,
// headers: { ...Object.fromEntries(res.headers), "www-authenticate": "Bearer" },
// });
// }
mutate res.headers in place · ctx.requestId on every responseTS · UTF-8 · LF
Recipe 3 — Log with onResponse
Anything that you'd call observation belongs in onResponse. The framework guarantees you can't accidentally break the request from here, which is exactly the constraint you want around log lines that someone added at 3 a.m. on a Friday.
src/middleware/access-log.ts
// onResponse — fire-and-forget observer. Cannot change anything.
// This is your logging/metrics/audit slot. By design it cannot accidentally
// break the response because the bytes are already in flight.
export function accessLog(): Hooks {
return {
onResponse(res) {
// `res` is the SAME response the client received.
logger.info(
{
status: res.status,
contentType: res.headers.get("content-type"),
bytes: res.headers.get("content-length"),
},
"http",
);
},
};
}
// Same place is perfect for metrics:
// metrics.histogram("http_response_status").observe(res.status);
// metrics.counter("http_responses_total").inc({ status: res.status });
fire-and-forget · cannot mutate · cannot replaceTS · UTF-8 · LF
Recipe 4 — Translate errors with onError
src/middleware/error-translation.ts
// onError — runs on the error path before the response is serialized.
// Return a Response to take over rendering; return nothing to fall through to
// the framework's default RFC 9457 serialization (which is what you usually want).
app.use({
onError(err, ctx) {
// 1. Always log. The framework will not double-log.
ctx?.log.error(
{
err,
requestId: ctx.requestId,
route: ctx.route?.operationId,
},
"unhandled",
);
// 2. Translate a specific upstream library error into a domain HttpError.
if (err instanceof PaymentGatewayTimeout) {
return new ServiceUnavailableError("Payments are slow right now").toResponse();
}
// 3. Anything else: fall through. The framework wraps unknown errors as
// InternalError and serializes them as problem+json with the right
// production redaction baked in.
},
});
log once · translate vendor errors · fall through for everything elseTS · UTF-8 · LF
Recipe 5 — Transform with afterHandle (carefully)
src/middleware/redact-email.ts
// afterHandle — transform the handler's return value before serialization.
// Use sparingly. 95% of the time you should just shape the body in the handler.
// Real use case: redact PII from a generic search response across many routes.
export function redactEmail(): Hooks {
return {
afterHandle(ctx, result) {
if (!ctx.route?.path.startsWith("/v1/search")) return; // narrow scope
// result is whatever the handler returned: { status, body, headers? }.
// Return a new value, or undefined to leave it alone.
const shaped = result as { status: number; body: Array<{ email?: string }> };
return {
...shaped,
body: shaped.body.map((row) =>
row.email ? { ...row, email: row.email.replace(/(?<=.).(?=[^@]*?@)/g, "*") } : row,
),
};
},
};
}
cross-cutting transform · narrow scope by route pathTS · UTF-8 · LF
Plugins: hooks + routes, encapsulated
For anything you'd ship as a unit — a metrics endpoint plus its onResponse observer, a sessions store plus its beforeHandle reader — use app.register(). Inside the plugin you get a child App whose hooks and routes are scoped to the mount point. The plugin can ship its own prefix, its own hooks, and its own auth defaults without leaking into the parent app:
src/plugins/observability.ts
// src/plugins/observability.ts — encapsulated plugin.
// Routes/hooks registered inside `register` are scoped to the child app.
import type { App, Hooks } from "@daloyjs/core";
export const observability = {
name: "observability",
register(child: App) {
child.use(stampServerTiming());
child.use(accessLog());
// You can mount routes too — they get the prefix from the parent's call.
child.route({
method: "GET",
path: "/metrics",
operationId: "metrics",
responses: { 200: { description: "Prometheus exposition" } },
handler: async () => ({
status: 200,
body: await metrics.render(),
headers: { "content-type": "text/plain; version=0.0.4" },
}),
});
},
};
// Mount it like this:
app.register(observability, {
prefix: "/_ops", // every route inside lives under /_ops
hooks: { /* extra hooks just for this plugin's scope */ },
auth: false, // turn off global auth inside the plugin
});
encapsulated plugin · prefix-scoped · own hooksTS · UTF-8 · LF
The cheat sheet
# What goes where — print this out, tape it to your monitor.
onRequest ↳ stuff that needs the raw Request (TLS termination metadata,
conditional request decoding). No context yet. Cannot decide.
beforeHandle ↳ AUTH. AUTHZ. RATE LIMITING. Anything that should prevent
the handler from running. THIS is where short-circuiting lives.
handler ↳ your code. Nothing else.
afterHandle ↳ shape transformations that span MANY routes (PII redaction,
envelope wrapping). 95% of the time, just shape it in the handler.
onSend ↳ response HEADERS for every response (success + error + OPTIONS).
Server-Timing, X-Request-Id, Strict-Transport-Security, CSP, …
onResponse ↳ FIRE-AND-FORGET observers. Logging. Metrics. Audit events.
Cannot change the response. This is by design.
onError ↳ translate framework-foreign errors into HttpError subclasses,
log once, fall through to the default problem+json serializer.
tape this to your monitor · seriouslyTS · UTF-8 · LF
The three patterns I keep deleting in code review
# Three patterns that look smart but bite you in production:
# 1) Mutating ctx.state in onResponse. Too late! The response is already gone.
# Put state changes in beforeHandle / afterHandle. Put OBSERVATION in onResponse.
# 2) Doing auth in afterHandle. The handler already ran (and probably hit the DB,
# and probably consumed the rate-limit budget). Auth belongs in beforeHandle.
# 3) Catching errors in beforeHandle to "swallow" them. The framework already
# does graceful error → problem+json conversion. Trust it. If you need to
# REWRITE the error, do it in onError. If you need to PREVENT it, do it in
# beforeHandle with a short-circuit Response or a thrown HttpError.
bookmark · re-read every six monthsTS · UTF-8 · LF
Wrapping up
Middleware in DaloyJS is one interface, Hooks, with six method slots. Three scopes, composed in a fixed order. One side-channel for errors. That is the whole machine. Once the mental model clicks, you stop asking where does this go and start asking which scope — which is a far more interesting question, and one whose answer you can usually argue about in a Slack thread without anyone getting hurt.
For the surrounding pieces: RFC 9457 errors is the contract onError serializes into; sessions is the most common beforeHandle consumer you'll write; and the bookstore tutorial shows the whole pipeline running against a real route table.
— Devlin