Handling Request Timeouts in Netlify Edge Functions
This guide is part of Memory and CPU Limits Across Edge Providers. It shows how to make a Netlify Edge Function fail on your terms when an upstream stalls, instead of hanging until the platform kills it and returns an error you cannot attribute.
The problem
A feature-flag service you call from middleware starts responding in eight seconds instead of eighty milliseconds. Nothing in your code changes. Your dashboards show normal CPU. Netlify’s logs show the edge function “completed”. Yet users see a spinner, then a generic gateway error, and your error tracker records nothing at all — because the failure happened in the platform layer above your handler, after your handler had already stopped being able to do anything about it.
Then someone tries the obvious fix, Promise.race against a setTimeout, and the symptom gets subtler rather than better: the response returns quickly, but the abandoned fetch keeps running, holding a connection and its buffers open, and under load the function starts failing for a completely different reason.
Root cause: two independent clocks, and only one of them is yours
Netlify Edge Functions run on Deno with two limits that people routinely conflate.
The CPU budget is roughly 50 ms of actual compute per invocation. It counts only time your JavaScript is executing — parsing, looping, hashing, serialising. It does not count time spent suspended at an await. You can wait ten seconds on a fetch and consume under a millisecond of CPU.
The wall-clock deadline is the platform’s patience: response headers must be on the wire within roughly 40 seconds, and the invocation is terminated if they are not. This clock runs while you are suspended. It is the one a slow upstream burns.
This is why the two failure modes look nothing alike. A tight loop over a large payload trips the CPU limit in a few milliseconds of wall time and gets terminated mid-execution. A stalled upstream never touches CPU at all and gets terminated after tens of seconds with no stack, no log line and no attribution — the platform reports a gateway failure, not an application error, so it lands in nobody’s error budget.
Step 1 — Give every upstream fetch its own hard timeout
AbortSignal.timeout(ms) is the correct primitive and it is available in the Deno runtime Netlify uses. Unlike a Promise.race, it aborts the underlying request: the connection is torn down, the response body is discarded, and the promise rejects with a TimeoutError. Nothing keeps running behind your back.
// lib/fetch-with-timeout.ts
export type FetchOutcome<T> =
| { ok: true; value: T; ms: number }
| { ok: false; reason: TimeoutReason; ms: number };
export type TimeoutReason =
| 'upstream_timeout' // our own deadline fired
| 'budget_exhausted' // no time left in the request budget
| 'client_abort' // the visitor went away
| 'upstream_error'; // it answered, but badly
export async function fetchJson<T>(
url: string,
init: RequestInit,
timeoutMs: number,
): Promise<FetchOutcome<T>> {
const started = Date.now();
if (timeoutMs <= 0) {
return { ok: false, reason: 'budget_exhausted', ms: 0 };
}
try {
const res = await fetch(url, {
...init,
// Combine our deadline with the client's own abort signal, so a
// visitor closing the tab also releases the upstream connection.
signal: init.signal
? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)])
: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
// Drain so the connection can be reused rather than reset.
await res.body?.cancel();
return { ok: false, reason: 'upstream_error', ms: Date.now() - started };
}
return { ok: true, value: (await res.json()) as T, ms: Date.now() - started };
} catch (err) {
const ms = Date.now() - started;
const name = err instanceof Error ? err.name : '';
if (name === 'TimeoutError') return { ok: false, reason: 'upstream_timeout', ms };
if (name === 'AbortError') return { ok: false, reason: 'client_abort', ms };
return { ok: false, reason: 'upstream_error', ms };
}
}
Two details carry most of the value. AbortSignal.any merges your deadline with the incoming request’s signal, so when a visitor navigates away you stop holding an upstream connection open on their behalf. And distinguishing TimeoutError from AbortError is what lets you tell “the upstream is slow” apart from “the user left” in your logs — two situations with identical latency signatures and completely different meanings.
Choosing the number is arithmetic, not taste. Take your target p99 for the whole middleware, subtract what the origin fetch needs, and divide the remainder among the optional calls. If a flag lookup normally answers in 40 ms, a 250 ms timeout is generous; a 5000 ms timeout is not a timeout, it is a delayed outage.
Step 2 — Share one deadline budget across the whole chain
Per-call timeouts do not compose. Four steps at 500 ms each is a 2-second worst case, and the fifth engineer to add a step will not have read the first four. The fix is to allocate one budget when the request arrives and have every step spend from it, so total latency is bounded by construction rather than by discipline.
// lib/deadline.ts
export interface Deadline {
/** Milliseconds left before the whole invocation must answer. */
remaining(): number;
/** Clamp a desired timeout to what the budget can still afford. */
allow(desiredMs: number, reserveMs?: number): number;
readonly startedAt: number;
}
export function createDeadline(totalMs: number): Deadline {
const startedAt = Date.now();
return {
startedAt,
remaining: () => Math.max(0, totalMs - (Date.now() - startedAt)),
allow(desiredMs, reserveMs = 0) {
// Never spend the reserve: it is what pays for the origin fetch
// and for serialising the response after the last step.
const spendable = Math.max(0, totalMs - (Date.now() - startedAt) - reserveMs);
return Math.min(desiredMs, spendable);
},
};
}
reserveMs is the part people leave out and then regret. If every optional step is allowed to consume the entire remaining budget, the mandatory origin fetch inherits zero milliseconds and the whole request fails even though each individual step behaved. Reserve the origin’s share up front and let the optional steps compete for what is left.
Step 3 — Decide fail-open or fail-closed for each step, in advance
A timeout is an absence of information, and what you should do with an absence depends entirely on what the call was for. There is no global right answer, only a per-step one, and it must be written down in the code rather than emerging from whichever catch block someone wrote last.
Fail open — proceed as if the call had returned a safe default — when the call was an enhancement. Feature flags, personalisation, an A/B assignment, a recommendation ranking: if the service is down, serving everyone the control variant is strictly better than serving nobody anything.
Fail closed — reject the request — when the call was a gate. Session verification against a revocation list, entitlement checks, a paywall, an abuse score. If you cannot confirm the visitor is allowed in, letting them in because a server was slow converts an availability incident into a security incident.
The uncomfortable middle case is a gate you can partly evaluate offline. Session verification is the canonical example: a signature check needs no network at all, as covered in verifying JWTs at the edge with jose, and only the revocation lookup needs an upstream. Split the step so the offline half always runs and only the network half is subject to a policy decision.
// lib/policy.ts
export type OnTimeout = 'open' | 'closed';
export interface Step<T> {
name: string;
onTimeout: OnTimeout;
/** Used only when onTimeout is 'open'. */
fallback?: T;
desiredMs: number;
}
export const STEPS = {
revocation: { name: 'revocation', onTimeout: 'closed', desiredMs: 200 },
flags: { name: 'flags', onTimeout: 'open', fallback: {}, desiredMs: 250 },
geoEnrich: { name: 'geo', onTimeout: 'open', fallback: null, desiredMs: 120 },
} as const satisfies Record<string, Step<unknown>>;
Declaring the policy as data makes it reviewable. Someone adding a step has to state an intent, and a reviewer can ask why an entitlement check is marked open without reading the handler.
Step 4 — Emit a reason code on every timeout
A timeout you cannot attribute is indistinguishable from general slowness, and general slowness is the hardest thing to get prioritised. Emit two things: a structured log line for your own analysis, and a response header so a support engineer can read the cause off a browser network tab without asking anyone for log access.
// lib/report.ts
import type { TimeoutReason } from './fetch-with-timeout.ts';
export interface TimeoutEvent {
step: string;
reason: TimeoutReason;
waitedMs: number;
budgetLeftMs: number;
policy: 'open' | 'closed';
}
export function reportTimeout(e: TimeoutEvent): void {
// One line, one JSON object — greppable and parseable by log drains.
console.log(JSON.stringify({ event: 'edge.timeout', ...e }));
}
export function applyTimeoutHeaders(headers: Headers, events: TimeoutEvent[]): void {
if (events.length === 0) return;
// e.g. "flags=upstream_timeout;250, geo=budget_exhausted;0"
headers.set(
'x-edge-degraded',
events.map((e) => `${e.step}=${e.reason};${e.waitedMs}`).join(', '),
);
// A degraded response must never be cached as if it were complete.
headers.set('cache-control', 'no-store');
}
The cache-control: no-store line is the one that prevents a five-minute incident from becoming an hour-long one. A response rendered with fallback flags is not the response the user should get once the flag service recovers, and a shared cache has no way to know that. For the wider pattern of tracking degraded upstreams see implementing circuit breakers in edge middleware and structured logging for edge functions.
Keep the header free of anything sensitive. Step names and reason codes are fine; upstream hostnames, internal error strings and user identifiers are not.
Step 5 — Wire it up with the Netlify config export
Netlify Edge Functions export a default handler and a config object that declares which paths the function runs on. Scoping it tightly matters here for the same reason it matters everywhere: a function that does not run cannot time out.
// netlify/edge-functions/gateway.ts
import { createDeadline } from '../../lib/deadline.ts';
import { fetchJson } from '../../lib/fetch-with-timeout.ts';
import { STEPS } from '../../lib/policy.ts';
import { applyTimeoutHeaders, reportTimeout, type TimeoutEvent } from '../../lib/report.ts';
const TOTAL_BUDGET_MS = 1500;
const ORIGIN_RESERVE_MS = 800;
export default async function gateway(
request: Request,
context: { next: () => Promise<Response> },
): Promise<Response> {
const deadline = createDeadline(TOTAL_BUDGET_MS);
const degraded: TimeoutEvent[] = [];
// --- Gate: revocation check, fail closed -------------------------------
const revocation = await fetchJson<{ revoked: boolean }>(
'https://auth.internal.example.com/revocations',
{ method: 'GET', headers: { authorization: request.headers.get('authorization') ?? '' }, signal: request.signal },
deadline.allow(STEPS.revocation.desiredMs, ORIGIN_RESERVE_MS),
);
if (!revocation.ok) {
reportTimeout({
step: STEPS.revocation.name,
reason: revocation.reason,
waitedMs: revocation.ms,
budgetLeftMs: deadline.remaining(),
policy: 'closed',
});
return new Response('Service temporarily unavailable', {
status: 503,
headers: {
'retry-after': '5',
'cache-control': 'no-store',
'x-edge-degraded': `revocation=${revocation.reason};${revocation.ms}`,
},
});
}
if (revocation.value.revoked) {
return new Response(null, { status: 401, headers: { 'cache-control': 'no-store' } });
}
// --- Enhancement: feature flags, fail open -----------------------------
let flags: Record<string, boolean> = {};
const flagsResult = await fetchJson<Record<string, boolean>>(
'https://flags.internal.example.com/v1/evaluate',
{ method: 'GET', signal: request.signal },
deadline.allow(STEPS.flags.desiredMs, ORIGIN_RESERVE_MS),
);
if (flagsResult.ok) {
flags = flagsResult.value;
} else {
const event: TimeoutEvent = {
step: STEPS.flags.name,
reason: flagsResult.reason,
waitedMs: flagsResult.ms,
budgetLeftMs: deadline.remaining(),
policy: 'open',
};
reportTimeout(event);
degraded.push(event);
}
// --- Continue to origin with whatever we learned -----------------------
const response = await context.next();
const headers = new Headers(response.headers);
headers.set('x-flags', Object.keys(flags).filter((k) => flags[k]).join(','));
applyTimeoutHeaders(headers, degraded);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
export const config = {
path: '/app/*',
excludedPath: ['/app/assets/*', '/app/health'],
cache: 'manual',
};
Note that context.next() — the call that goes to your origin or static asset — is deliberately not wrapped in a timeout. It is the request’s whole purpose, it is what the reserve exists to pay for, and aborting it produces nothing you could usefully return. The budget’s job is to guarantee it still has room to run.
Local versus production divergence
| Behaviour | netlify dev locally |
Netlify Edge production |
|---|---|---|
| CPU limit | Not enforced; your laptop is the limit | Roughly 50 ms of execution per invocation |
| Wall-clock deadline | Effectively none | Roughly 40 s to first response headers |
| Upstream latency | LAN or localhost, single-digit ms | Cross-region, tens to hundreds of ms |
AbortSignal.timeout |
Present in the local Deno runtime | Present, identical semantics |
| Termination symptom | An unhandled rejection in your terminal | A platform gateway error with no stack |
request.signal aborts |
Rarely fires; you do not close the tab | Fires routinely on navigation away |
| Concurrency | One request at a time | Many, sharing the same isolate |
The third row is why timeout values chosen locally are almost always too tight in the first deploy and then over-corrected to uselessly loose. Set them from production p99 measurements of the upstream, not from what felt instant on your machine. The related trap for the CPU clock is covered in avoiding CPU time limit errors in Cloudflare Workers, which has the same shape on Netlify.
Validating with Vitest
Timeout behaviour is exactly the kind of logic that never gets exercised until an incident, so it must be tested. Inject a fake slow fetch and use fake timers so the suite runs in milliseconds rather than seconds.
// timeouts.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchJson } from './lib/fetch-with-timeout';
import { createDeadline } from './lib/deadline';
/** A fetch that answers after `delayMs`, respecting AbortSignal. */
function slowFetch(delayMs: number, body: unknown = { ok: true }) {
return vi.fn((_url: string, init?: RequestInit) =>
new Promise<Response>((resolve, reject) => {
const id = setTimeout(
() => resolve(new Response(JSON.stringify(body), { status: 200 })),
delayMs,
);
init?.signal?.addEventListener('abort', () => {
clearTimeout(id);
const err = new Error('aborted');
// TimeoutError is what AbortSignal.timeout surfaces.
err.name = (init.signal as AbortSignal).reason?.name ?? 'AbortError';
reject(err);
});
}),
);
}
describe('fetchJson', () => {
const realFetch = globalThis.fetch;
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
globalThis.fetch = realFetch;
});
it('returns the value when the upstream answers inside the timeout', async () => {
globalThis.fetch = slowFetch(50, { flagA: true }) as unknown as typeof fetch;
const promise = fetchJson<{ flagA: boolean }>('https://x.test', {}, 250);
await vi.advanceTimersByTimeAsync(60);
await expect(promise).resolves.toMatchObject({ ok: true, value: { flagA: true } });
});
it('reports upstream_timeout when the upstream is slower than the budget', async () => {
globalThis.fetch = slowFetch(5_000) as unknown as typeof fetch;
const promise = fetchJson('https://x.test', {}, 250);
await vi.advanceTimersByTimeAsync(300);
await expect(promise).resolves.toMatchObject({ ok: false, reason: 'upstream_timeout' });
});
it('aborts the upstream rather than abandoning it', async () => {
const fake = slowFetch(5_000);
globalThis.fetch = fake as unknown as typeof fetch;
const promise = fetchJson('https://x.test', {}, 100);
await vi.advanceTimersByTimeAsync(150);
await promise;
const passedSignal = fake.mock.calls[0][1]?.signal as AbortSignal;
expect(passedSignal.aborted).toBe(true);
});
it('refuses immediately when the budget is already spent', async () => {
const fake = slowFetch(10);
globalThis.fetch = fake as unknown as typeof fetch;
const result = await fetchJson('https://x.test', {}, 0);
expect(result).toMatchObject({ ok: false, reason: 'budget_exhausted' });
expect(fake).not.toHaveBeenCalled();
});
});
describe('createDeadline', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('clamps a step timeout to the remaining budget minus the reserve', () => {
const deadline = createDeadline(1_500);
vi.advanceTimersByTime(600);
// 1500 - 600 elapsed - 800 reserve = 100 spendable, so 250 is clamped.
expect(deadline.allow(250, 800)).toBe(100);
});
it('never lends out the reserve', () => {
const deadline = createDeadline(1_000);
vi.advanceTimersByTime(300);
expect(deadline.allow(500, 800)).toBe(0);
});
});
The third test is the one that catches the Promise.race regression. Asserting on the resolved value only proves you returned quickly; asserting that the signal was aborted proves the upstream request actually stopped. The fourth encodes the rule that an exhausted budget must not issue the call at all — issuing a request you have no time to wait for is pure waste. See unit testing edge middleware with Vitest for the surrounding harness.
Common pitfalls and resolutions
Promise.race against a setTimeout — returns on time but leaves the original fetch running, holding a connection and its buffers. Always use AbortSignal.timeout, which tears the request down.
Timeouts that add up — four independent 500 ms timeouts is a two-second worst case. Allocate one budget per request and clamp every step against the remaining balance.
The origin fetch starved by optional steps — the enhancement calls consumed the whole budget. Reserve the origin’s share with reserveMs and never lend it out.
A gate marked fail-open by accident — the catch block returns a default because that is what the previous step did. Declare the policy as data next to the step so a reviewer can see it.
Degraded responses cached — a page rendered with fallback flags gets stored in a shared cache and outlives the incident. Set cache-control: no-store whenever any step degraded.
“CPU is fine, so it is not us” — CPU does not accrue while suspended at an await. A stalled upstream produces flat CPU graphs and a hung request; look at wall-clock elapsed time per step instead.
Client aborts counted as upstream failures — a visitor navigating away raises AbortError, not TimeoutError. Separate the two or your timeout rate will track your bounce rate.
Production deployment checklist
Frequently Asked Questions
Does waiting on a slow fetch consume my CPU budget?
No. The CPU budget counts only time your JavaScript is actually executing, and an await suspends execution entirely. You can wait ten seconds on an upstream and consume under a millisecond of CPU, which is why flat CPU graphs are never evidence that timeouts are not your problem. The limit a stalled upstream burns is the wall-clock deadline before response headers must be sent.
Why is Promise.race with setTimeout not good enough?
Because it only stops you waiting; it does not stop the request. The original fetch keeps running with its connection and buffers held open, and under load those abandoned requests accumulate until the function starts failing for an unrelated reason. AbortSignal.timeout actually tears the connection down, which is the behaviour you wanted.
How do I choose a timeout value?
Work backwards from the latency you are willing to serve. Take your target for the whole middleware, subtract the reserve the origin fetch needs, and divide the remainder among the optional calls. Size each one against the measured production p99 of that upstream rather than local latency, because a value tuned on localhost will be far too tight in production.
When should a timeout fail open rather than closed?
Fail open when the call was an enhancement, such as feature flags, personalisation or a recommendation ranking, because serving everyone the control experience beats serving nobody anything. Fail closed when the call was a gate, such as a revocation check, an entitlement lookup or an abuse score, because letting someone in on the grounds that a server was slow turns an availability incident into a security incident.
Should I cache a response that was produced with fallback values?
No. Set cache-control to no-store whenever any step degraded. A page rendered with default flags is not what the visitor should receive once the upstream recovers, and a shared cache has no way to distinguish it from a complete response, so a five minute incident becomes an hour long one.
How do I tell a client abort apart from an upstream timeout?
By the error name. AbortSignal.timeout surfaces a TimeoutError, whereas a visitor navigating away aborts the incoming request signal and surfaces an AbortError. Record them as separate reason codes, otherwise your timeout rate will quietly track your bounce rate and you will chase an upstream problem that does not exist.