Error Handling in Edge Middleware Chains
This guide is part of Building a Custom Middleware Chain. It shows how to put a boundary around every stage in the chain so that one stage throwing degrades a single feature instead of destroying the request.
A middleware chain is the most dangerous piece of code you deploy, because it runs on every request. A bug in a route handler breaks one route; a bug in a middleware stage breaks the site. And the failure modes are not exotic — a feature-flag service returns HTML instead of JSON, a cookie contains a character that breaks a parser, a geo header is absent in one PoP, an upstream takes three seconds instead of thirty milliseconds. None of those are your code being wrong. They are the world being the world.
The discipline that survives contact with production is simple to state and easy to get wrong: every stage runs inside a boundary, every stage declares in advance what should happen when it fails, and nothing about the failure ever reaches the client except an opaque identifier.
The problem
You compose four stages — geo enrichment, authentication, feature flags, security headers — into one exported middleware function. It works. Two weeks later the flag service has a bad deploy and starts returning 502 with an HTML body. Your stage calls res.json(), JSON.parse throws on <, the rejection propagates out of your handler, and the platform turns it into a generic error page for every request to the site. Authentication was fine. The origin was fine. A non-essential personalization lookup took the whole site down.
The second failure is the mirror image. Someone, having been burned by that outage, wraps the entire chain in one try { ... } catch { return NextResponse.next(); }. Now the site never 500s — and when the authentication stage throws because the JWKS fetch timed out, the request sails through to the origin unauthenticated. A blanket catch converted an availability incident into a security incident.
The third is quieter and shows up in a penetration-test report: your error page includes err.stack, which names your source files, your bundler layout, and the internal hostname of the service that failed.
Root cause: the isolate has one failure domain and no supervisor
An edge middleware chain is not a process tree. There is no supervisor that can restart a failed stage, no per-stage sandbox, and no equivalent of a request-scoped worker that can die on its own. Composition is just function calls inside one isolate invocation, so an unhandled rejection anywhere in the chain aborts the entire invocation. The platform then substitutes its own response, and you have no control over what that response says:
Cloudflare Workers surfaces an uncaught throw as error 1101 (“Worker threw exception”) with a Cloudflare-branded page. Vercel Edge Middleware returns a 500 carrying x-vercel-error: MIDDLEWARE_INVOCATION_FAILED. Netlify Edge Functions return a 500 and log the stack in the function log. In all three cases the response is generated after your code has already lost control, so you cannot attach a correlation id, you cannot set cache-control: no-store, and you cannot decide whether failing this particular request was even necessary.
The second constraint is about reporting. You want the failure to reach your observability sink, and that means an outbound fetch — tens or hundreds of milliseconds against an external collector. If you await it on the request path you have made every error request slow on top of being an error. Edge runtimes solve this with ctx.waitUntil (Cloudflare and Netlify pass a context object; Vercel exposes waitUntil from @vercel/functions and Next.js provides it on the middleware event), which keeps the invocation alive after the response has been flushed. Reporting belongs there, always.
Step 1 — Give every stage a failure policy in its type
The mistake that makes error handling unmanageable is deciding the policy at the catch site. By the time you are in a catch block you no longer know whether this stage was gating access or decorating a header. Put the decision in the stage’s own declaration, where the author of the stage — who understands what it does — has to write it down.
// lib/chain/types.ts
/** The subset of the platform context the chain needs. */
export interface EdgeContext {
waitUntil(promise: Promise<unknown>): void;
}
/** Mutable state threaded through the chain. */
export interface ChainState {
requestId: string;
/** Headers that will be forwarded to the origin. */
headers: Headers;
/** Cross-stage data; see the chain's context-passing conventions. */
store: Map<string, unknown>;
/** Names of stages that failed open on this request. */
degraded: string[];
}
/**
* fail-open : the request continues without this stage's effect.
* fail-closed: the request stops here with an error response.
*/
export type FailurePolicy = 'fail-open' | 'fail-closed';
export interface Stage {
readonly name: string;
readonly onError: FailurePolicy;
/** Wall-clock budget. A stage that exceeds it is treated as a failure. */
readonly timeoutMs?: number;
/**
* Return a Response to short-circuit the chain, or void to continue.
* Mutate state.headers / state.store to pass work downstream.
*/
run(request: Request, state: ChainState, ctx: EdgeContext): Promise<Response | void>;
}
export interface StageFailure {
requestId: string;
stage: string;
policy: FailurePolicy | 'chain';
kind: 'throw' | 'timeout';
name: string;
message: string;
stack?: string;
path: string;
at: string;
}
onError is not optional and has no default. If a required field forces every stage author to type either 'fail-open' or 'fail-closed', the decision gets made deliberately once, at authoring time, instead of accidentally on the night of the incident.
Step 2 — Choose fail-open or fail-closed per stage
The rule is about what the downstream system assumes. Fail closed whenever continuing would let a request reach code that believes a check already happened. Fail open whenever the stage only adds information that downstream code can live without.
Authentication and authorization are always fail-closed: skipping them is the entire attack. Rate limiting is fail-closed if your origin cannot survive an unlimited burst, and fail-open if the limiter is protecting a nice-to-have. Security headers are fail-closed in spirit — but they should be written so they cannot fail at all, since setting a static header has no failure mode. Geo enrichment, A/B bucketing, personalization and feature flags are fail-open, provided every downstream reader has a sane default for “absent”.
There is a third answer that is better than either: serve a safe default. A flag stage that cannot reach its service should not fail open into “no flags at all” if that means every user drops out of every experiment; it should fall back to a compiled-in default set. A geo stage that gets no country header should set unknown, not omit the field. Fail-open works only when the absence of the stage’s output is a state downstream code already handles.
Step 3 — Build compose() with a boundary around every stage
The composer owns three responsibilities: run each stage inside try/catch, enforce a per-stage time budget, and keep a boundary around itself so that a bug in the composer is also converted into a controlled response.
// lib/chain/compose.ts
import type {
ChainState, EdgeContext, Stage, StageFailure, FailurePolicy,
} from './types';
export type Reporter = (failure: StageFailure) => Promise<void>;
class StageTimeout extends Error {
constructor(stage: string, ms: number) {
super(`stage ${stage} exceeded ${ms}ms`);
this.name = 'StageTimeout';
}
}
function withTimeout<T>(work: Promise<T>, ms: number, stage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const bell = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new StageTimeout(stage, ms)), ms);
});
return Promise.race([work, bell]).finally(() => clearTimeout(timer));
}
function describe(
err: unknown, stage: string, state: ChainState, policy: FailurePolicy | 'chain', path: string,
): StageFailure {
const kind = err instanceof StageTimeout ? 'timeout' : 'throw';
if (err instanceof Error) {
return {
requestId: state.requestId, stage, policy, kind,
name: err.name, message: err.message, stack: err.stack,
path, at: new Date().toISOString(),
};
}
return {
requestId: state.requestId, stage, policy, kind,
name: 'NonError', message: String(err), path, at: new Date().toISOString(),
};
}
/** A reporter must never reject: an unhandled rejection inside waitUntil is
* logged by the platform as an invocation exception. */
function neverThrows(p: Promise<unknown>): Promise<void> {
return p.then(() => undefined, () => undefined);
}
export function compose(stages: readonly Stage[], report: Reporter) {
return async function chain(request: Request, ctx: EdgeContext): Promise<Response> {
const path = new URL(request.url).pathname;
const state: ChainState = {
requestId: crypto.randomUUID(), // Web Crypto global, not node:crypto
headers: new Headers(request.headers),
store: new Map(),
degraded: [],
};
try {
for (const stage of stages) {
try {
const result = await withTimeout(
stage.run(request, state, ctx),
stage.timeoutMs ?? 200,
stage.name,
);
if (result instanceof Response) return finalize(result, state);
} catch (err) {
const failure = describe(err, stage.name, state, stage.onError, path);
ctx.waitUntil(neverThrows(report(failure)));
if (stage.onError === 'fail-open') {
state.degraded.push(stage.name);
continue; // the only place the chain resumes
}
return finalize(errorResponse(state.requestId), state);
}
}
return finalize(await forward(request, state), state);
} catch (err) {
// The machinery itself failed: forward(), finalize(), or a composer bug.
ctx.waitUntil(neverThrows(report(describe(err, 'chain', state, 'chain', path))));
return errorResponse(state.requestId);
}
};
}
async function forward(request: Request, state: ChainState): Promise<Response> {
return fetch(new Request(request, { headers: state.headers }));
}
function finalize(response: Response, state: ChainState): Response {
const headers = new Headers(response.headers);
headers.set('x-request-id', state.requestId);
if (state.degraded.length > 0) headers.set('x-degraded', state.degraded.join(','));
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
Two details carry most of the weight. continue appears exactly once, so there is a single place in the codebase where a request survives a failure — that line is what a reviewer reads to understand your risk posture. And the timeout does not cancel the underlying work; Promise.race only stops waiting. The abandoned promise keeps running and keeps consuming CPU against your budget, which matters on platforms with a hard CPU ceiling. Give the stage an AbortSignal if the work is a fetch you can genuinely cancel.
Step 4 — Return a 500 that carries a correlation id and nothing else
The error response has exactly one job on the client side: give the user a string they can quote to support. Everything else about the failure belongs in your logs.
// lib/chain/error-response.ts
export function errorResponse(requestId: string, status = 500): Response {
return new Response(
JSON.stringify({ error: 'internal_error', requestId }),
{
status,
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'x-request-id': requestId,
},
},
);
}
/** HTML variant for document requests, so a browser does not render raw JSON. */
export function errorPage(requestId: string, status = 500): Response {
const body = `<!doctype html><meta charset="utf-8"><title>Something went wrong</title>
<h1>Something went wrong</h1>
<p>Please try again. If it keeps happening, quote this reference:</p>
<p><code>${requestId}</code></p>`;
return new Response(body, {
status,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
'x-request-id': requestId,
},
});
}
cache-control: no-store is not decoration. A 500 without it can be stored by a shared cache tier and replayed to unrelated users long after the underlying fault is fixed, and because the body contains a request id, you will then spend an afternoon chasing a correlation id that belongs to a request from yesterday. The same rule applies to any short-circuit response your chain emits; see returning 401 responses from edge middleware for the authentication case.
requestId is safe to expose precisely because it is meaningless on its own. It is a random UUID that indexes a log line. Nothing about it tells an attacker which stage failed, what the upstream was, or how your code is laid out.
Step 5 — Report through ctx.waitUntil so the user never waits for it
The reporter is an ordinary fetch to your collector. What makes it safe is that it is handed to ctx.waitUntil rather than awaited, and that it can never reject.
// lib/chain/report.ts
import type { Reporter, StageFailure } from './types';
export function httpReporter(endpoint: string, token: string): Reporter {
return async (failure: StageFailure) => {
// Always emit the structured line first: the platform log is the one sink
// that cannot itself be down.
console.error(JSON.stringify({ event: 'stage.failure', ...failure, stack: undefined }));
try {
await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
// The stack goes here and only here.
body: JSON.stringify(failure),
signal: AbortSignal.timeout(3_000),
});
} catch {
console.error(JSON.stringify({
event: 'stage.report_failed',
requestId: failure.requestId,
stage: failure.stage,
}));
}
};
}
Three properties matter. The collector call is bounded by AbortSignal.timeout, because a waitUntil task that hangs holds the invocation open and, on Cloudflare, can be cut short by the platform’s own limit — leaving you with neither the report nor a clean shutdown. The console.error line runs first and omits the stack, so your always-on log stream stays cheap and greppable while the full record goes to the sink; this is the same structured-logging discipline described in structured logging for edge functions. And the whole thing is wrapped again by neverThrows in the composer, because a reporter that rejects inside waitUntil produces exactly the uncaught exception you built this machinery to prevent.
Wiring and matcher configuration
The exported entry point is thin: build the stage list once at module scope, then hand the request to the composed chain.
// middleware.ts
import { compose } from './lib/chain/compose';
import { httpReporter } from './lib/chain/report';
import { geoStage, authStage, flagStage, securityHeadersStage } from './lib/stages';
import type { NextRequest } from 'next/server';
// Module scope: built once per isolate, shared by every request.
const chain = compose(
[geoStage, authStage, flagStage, securityHeadersStage],
httpReporter(process.env.ERROR_SINK_URL!, process.env.ERROR_SINK_TOKEN!),
);
export default function middleware(request: NextRequest, event: { waitUntil(p: Promise<unknown>): void }) {
return chain(request, { waitUntil: (p) => event.waitUntil(p) });
}
export const config = {
runtime: 'edge',
matcher: [
// Skip assets: a stage that throws on a font request is still a billed
// invocation, and there is nothing to degrade gracefully into.
'/((?!_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml).*)',
],
};
Keeping the stage array at module scope matters for a subtle reason beyond cost: stage construction is itself code that can throw, and a throw at module scope fails the isolate before your composer exists, producing exactly the platform error page you are trying to avoid. Anything that could fail — parsing a config blob, compiling a regex from an environment variable — belongs inside a stage’s run, where the boundary can catch it.
Local versus production divergence
| Behaviour | Local dev | Edge production |
|---|---|---|
| Uncaught throw | Full stack rendered in the browser overlay | Platform error page: Cloudflare 1101, Vercel MIDDLEWARE_INVOCATION_FAILED, Netlify bare 500 |
ctx.waitUntil after response |
Usually runs to completion; process is idle | Bounded by the platform; long tasks can be cut short |
console.error |
Terminal, immediately | Sampled log stream; may be dropped under high volume |
| Reporter endpoint | Often unreachable, so every failure double-logs | Reachable, but subject to egress rules per PoP |
| Timeouts | Rarely trip — upstreams are on localhost | Trip regularly; this is where timeoutMs earns its place |
| Isolate reuse | Reset on every save, so module scope is always fresh | Persistent, so a poisoned module-scope value survives many requests |
AbortSignal.timeout |
Present in Node 18+ and Miniflare | Present natively on all three platforms |
The last row is the one that bites. Locally, a bad value cached at module scope disappears the moment you save a file. In production it lives for the isolate’s lifetime, which is why any module-scope cache the chain depends on should hold a failure only briefly, or not at all.
Validating with Vitest
The composer is pure enough to test without a runtime: stages are plain objects, the context is a two-line stub, and fetch is the only thing you need to intercept.
// compose.test.ts
import { describe, expect, it, vi } from 'vitest';
import { compose } from './lib/chain/compose';
import type { Stage, StageFailure } from './lib/chain/types';
function stubContext() {
const tasks: Promise<unknown>[] = [];
return { ctx: { waitUntil: (p: Promise<unknown>) => { tasks.push(p); } }, tasks };
}
function stage(name: string, onError: Stage['onError'], run: Stage['run'], timeoutMs?: number): Stage {
return { name, onError, timeoutMs, run };
}
const ok = (name: string) =>
stage(name, 'fail-open', async (_req, state) => { state.headers.set(`x-${name}`, '1'); });
const boom = (name: string, onError: Stage['onError']) =>
stage(name, onError, async () => { throw new Error(`secret upstream db-7.internal is down`); });
describe('compose', () => {
it('continues past a fail-open stage and marks the response degraded', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('body', { status: 200 })));
const { ctx, tasks } = stubContext();
const report = vi.fn(async () => {});
const res = await compose([ok('geo'), boom('flags', 'fail-open'), ok('headers')], report)(
new Request('https://example.com/dashboard'), ctx,
);
expect(res.status).toBe(200);
expect(res.headers.get('x-degraded')).toBe('flags');
expect(res.headers.get('x-request-id')).toMatch(/^[0-9a-f-]{36}$/);
expect(tasks).toHaveLength(1);
expect(report).toHaveBeenCalledTimes(1);
});
it('stops at a fail-closed stage and never reaches the origin', async () => {
const upstream = vi.fn(async () => new Response('body'));
vi.stubGlobal('fetch', upstream);
const { ctx } = stubContext();
const res = await compose([boom('auth', 'fail-closed'), ok('headers')], async () => {})(
new Request('https://example.com/dashboard'), ctx,
);
expect(res.status).toBe(500);
expect(upstream).not.toHaveBeenCalled();
expect(res.headers.get('cache-control')).toBe('no-store');
});
it('never leaks the error message or a stack to the client', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('body')));
const { ctx } = stubContext();
const res = await compose([boom('auth', 'fail-closed')], async () => {})(
new Request('https://example.com/dashboard'), ctx,
);
const body = await res.text();
expect(body).not.toContain('db-7.internal');
expect(body).not.toContain('at ');
expect(JSON.parse(body)).toEqual({ error: 'internal_error', requestId: expect.any(String) });
});
it('classifies an over-budget stage as a timeout and reports it', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('body')));
const { ctx } = stubContext();
const seen: StageFailure[] = [];
const slow = stage('flags', 'fail-open', () => new Promise(() => {}), 20);
const res = await compose([slow], async (f) => { seen.push(f); })(
new Request('https://example.com/dashboard'), ctx,
);
expect(res.status).toBe(200);
expect(seen[0]).toMatchObject({ stage: 'flags', kind: 'timeout', policy: 'fail-open' });
});
it('survives a reporter that rejects', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('body')));
const { ctx, tasks } = stubContext();
const res = await compose([boom('flags', 'fail-open')], async () => { throw new Error('sink down'); })(
new Request('https://example.com/dashboard'), ctx,
);
expect(res.status).toBe(200);
await expect(Promise.all(tasks)).resolves.toBeDefined();
});
});
The third test is the one to protect with a comment, because it is the only assertion standing between a refactor and a disclosure bug. Asserting on the absence of a known-secret substring is more durable than asserting on the exact body shape: it keeps failing even if the response format changes. The last test matters just as much — it proves that neverThrows actually holds, which is the difference between a reported failure and a second, invisible one.
Common pitfalls and resolutions
One try/catch around the whole chain — the most common shape, and the one that silently fails open on authentication. Move the boundary inside the loop so each stage’s declared policy is honoured.
catch (err) { return new Response(err.message, { status: 500 }) } — leaks upstream hostnames, connection strings and internal service names into a page a user can screenshot. Return the request id; send the message to the sink.
The reporter is awaited on the request path — every failing request now costs an extra round trip to your collector, exactly when your collector is most likely to be slow. Hand it to ctx.waitUntil.
A reporter that can reject inside waitUntil — produces an unhandled rejection the platform records as an invocation exception, so your error handling generates errors. Wrap it so it always resolves.
Errors cached at a shared tier — the 500 lacked cache-control: no-store and is now being replayed to users after the fault is fixed. Set it on every short-circuit response, not just the error path.
Promise.race treated as cancellation — the losing promise keeps running and keeps burning CPU, which on Cloudflare can push you into the per-invocation limit described in avoiding CPU time limit errors in Cloudflare Workers. Pass an AbortSignal into anything that supports one.
Non-Error throws — throw 'nope' and rejected promises carrying plain objects both reach your catch without a .message or .stack. Normalize in one place, as describe does, or the reporter itself will throw on err.message.
A stage that mutates shared state before throwing — the chain continues with half-applied headers. Have fail-open stages compute their result first and write to state only on the last line.
Production deployment checklist
Frequently Asked Questions
Should a middleware chain ever fail open on authentication?
No. Failing open on authentication means an unauthenticated request reaches code that assumes a check already happened, which converts an availability problem into a security problem. If the identity provider is unreachable, return a 503 and let the request fail loudly rather than letting it through.
Why put the failure policy in the stage type instead of in the catch block?
By the time you are inside a catch block you no longer know whether the stage was gating access or decorating a header, so you end up choosing one blanket behaviour for everything. Making the policy a required field on the stage forces the person who wrote the stage, and understands what it does, to decide once and in writing.
Is it safe to return a request id to the client?
Yes, provided it is random and carries no structure. A UUID is meaningless on its own and only becomes useful when combined with your logs, so it tells an attacker nothing about which stage failed or how your code is laid out, while giving support a single string to search on.
Does Promise.race actually cancel a slow stage?
No. Racing a promise against a timer only stops you waiting for the result; the underlying work keeps running and keeps consuming CPU against your invocation budget. To genuinely stop the work you must pass an AbortSignal into something that honours it, such as fetch.
What happens if the error reporter itself throws inside waitUntil?
The platform records it as an unhandled rejection and counts it as an invocation exception, so your error handling generates the very failures it exists to prevent. Wrap the reporter so it always resolves, and log a short fallback line to the platform log when the collector is unreachable.
How do I know when the chain is quietly failing open?
Emit the list of degraded stages as a response header and as a field on your structured log line, then alert on its rate. Without that signal a fail-open stage can be broken for weeks while every dashboard shows a healthy 200 rate.