Skip to content

Cancellation and timeouts

Work started for a request can outlive the reason for it: the client disconnects, or an upstream call takes longer than anyone will wait. The platform’s AbortSignal handles both.

ctx.req.signal aborts when the client disconnects. Pass it to the work the handler starts, and that work stops when nobody is left to receive the answer:

route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/rates"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/rates",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<Rates>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: async (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
const
const res: Response
res
= await fetch("https://rates.example.com/latest", {
signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
:
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
signal: AbortSignal

The read-only signal property of the Request interface returns the AbortSignal associated with the request.

MDN Reference

signal
});
const
const rates: Rates
rates
: Rates = await
const res: Response
res
.json();
return
const rates: Rates
rates
;
},
});

fetch, Bun’s own APIs, node:timers/promises and most database drivers take a signal. @tetsujs/sse passes one to its generator for you; see Streaming.

AbortSignal.timeout() aborts after a delay, and AbortSignal.any() combines it with the request’s signal, so the work stops at whichever comes first:

route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/quotes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/quotes",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<{
id: string;
}>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: async (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
const
const signal: AbortSignal
signal
= AbortSignal.any([
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
signal: AbortSignal

The read-only signal property of the Request interface returns the AbortSignal associated with the request.

MDN Reference

signal
, AbortSignal.timeout(5_000)]);
return await
const upstream: {
fetch(options: {
signal: AbortSignal;
}): Promise<{
id: string;
}>;
}
upstream
.fetch({
signal: AbortSignal
signal
});
},
});

An expired deadline rejects with a DOMException named TimeoutError. Left alone, it answers 500 and is reported as unhandled. An onError hook turns it into the answer you mean:

const upstreamTimeout = hook.onError((ctx) => {
if (ctx.
error: unknown
error
instanceof DOMException && ctx.
error: DOMException
error
.
name: string

The name read-only property of the one of the strings associated with an error name.

MDN Reference

name
=== "TimeoutError") {
return Response.json(errorBody(504, "UPSTREAM_TIMEOUT"), {
status?: number | undefined
status
: 504 });
}
});

A signal stops only the work it was passed to

Section titled “A signal stops only the work it was passed to”

Aborting a signal does not stop the handler. It stops the calls that were given the signal; everything else runs on. A query started without one runs to completion, and a loop that never checks the signal keeps looping.

So pass the signal to every call that may take long, and in a loop of your own call signal.throwIfAborted() between steps.

A framework timeout would answer 503 once a request takes too long. The framework does not offer one, because JavaScript cannot interrupt a running function: the handler would keep running, mid-query or mid-payment, after the client was told the request failed. A client that retries on 503 would then run the operation twice.

A deadline that really stops the work has to be passed to the work, and only the handler knows which calls are safe to abandon. AbortSignal.any() with AbortSignal.timeout() is that deadline, in one line. For a retry that must not repeat its effect, the client sends an idempotency key and the handler remembers what it answered.

Bun closes a connection that sends nothing for its idleTimeout, set where the application is served. ctx.server.timeout(ctx.req, seconds) changes it for one request, such as a long upload or a slow report:

route({
method: "POST"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "POST",
path: "/reports"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/reports",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<{
rows: number;
}>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: async (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
server: Bun.Server<unknown>

The server handling this request — the real Bun.Server, whole and unguarded, like req an escape hatch to the platform.

The way to reach connection- and server-level facts a Request does not carry: ctx.server.requestIP(ctx.req) for rate limiting by address, ctx.server.timeout(ctx.req, seconds) for a per-request idle timeout.

An address is in the form the socket reports it: a server listening on both stacks — Bun's default — reports an IPv4 client as ::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.

Deliberately not a facade: every hook is code the application author wrote or vetted, and hiding stop/reload from in-process code protects nothing. They are still process-level operations with no business inside a request — calling ctx.server.stop() from a hook takes the whole listener down.

upgrade is for the endpoints ws() declares, which the application upgrades itself. A socket upgraded by hand reaches the application's websocket handler with no endpoint on it: the connection drops, and each of its events is reported as a failure.

server
.timeout(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
, 120);
return await
const reports: {
build(signal: AbortSignal): Promise<{
rows: number;
}>;
}
reports
.build(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
signal: AbortSignal

The read-only signal property of the Request interface returns the AbortSignal associated with the request.

MDN Reference

signal
);
},
});

An event stream from @tetsujs/sse sets its own, above its heartbeat; see Idle connections. On a unix socket Bun ignores ctx.server.timeout(), and only the server’s idleTimeout applies.