Skip to main content

How to Cancel an Upstream Request When the Browser Disconnects in Next.js

· 6 min read
Jagdish Kumawat
Founder @ Dewiride

A Next.js route handler often calls another service — an API, a database, an AI model — and waits for it. If the browser gives up, closes the tab or presses Stop, that call keeps running for nobody unless you tell it otherwise. Passing request.signal to fetch ties the two together. This guide shows the one line, and a side-by-side run of what happens with and without it.

How a Request Tells You It Has Ended​

Every route handler receives the browser's Request, and every Request has a signal: an AbortSignal that fires when the request ends early — the browser disconnects, or a client times out. fetch accepts a signal too, and cancels its request when that signal fires. Handing one to the other means: this upstream call lives exactly as long as the browser's request.

Prerequisites​

  • A Next.js app with the App Router. The examples use Next.js 16.4.6.
  • curl, to play a client that gives up.

Step 1: A Slow Upstream Service​

For the demonstration, the "upstream" is a route in the same app: ten steps of half a second, which checks between steps whether its own caller has gone, and logs what happened:

app/api/upstream/route.ts
// A slow service: ten steps of half a second, which stops when its caller goes away.
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get("id");
console.log(`upstream ${id}: started`);
for (let step = 1; step <= 10; step++) {
if (request.signal.aborted) {
console.log(
`upstream ${id}: the caller went away, stopped after step ${step - 1}`,
);
return new Response(null, { status: 499 });
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
console.log(`upstream ${id}: finished all 10 steps`);
return Response.json({ done: true });
}

In real life this is any service that does work per request — and many, like AI model APIs, also stop when their caller disconnects.

Step 2: Pass request.signal On​

The route the browser calls, with the signal passed to fetch:

app/api/report/route.ts
// Calls the slow service. The call lives exactly as long as the browser's request.
export async function GET(request: Request) {
const upstream = await fetch(
new URL("/api/upstream?id=with-signal", request.url),
{
signal: request.signal,
},
);
return Response.json(await upstream.json());
}

macOS-style code window titled app/api/report/route.ts showing a GET handler that calls fetch for the upstream service with signal set to request.signal, and returns the upstream's JSON

For comparison, the same route without the signal:

app/api/report-no-signal/route.ts
// The same call, without passing the request's signal on.
export async function GET(request: Request) {
const upstream = await fetch(
new URL("/api/upstream?id=without-signal", request.url),
);
return Response.json(await upstream.json());
}

Step 3: Leave Early, Both Ways​

Build and start the app (npm run build, then npm start), so that no route is compiled on its first request. Then call each route with a client that gives up after one second — --max-time 1:

Terminal
curl --max-time 1 http://localhost:3000/api/report
curl --max-time 1 http://localhost:3000/api/report-no-signal
Output
curl: (28) Operation timed out after 1009 milliseconds with 0 bytes received
curl: (28) Operation timed out after 1001 milliseconds with 0 bytes received

Both times, the client left after a second. The app's terminal shows what happened upstream:

Output (the app's terminal)
upstream with-signal: started
⨯ [ResponseAborted: ]
upstream with-signal: the caller went away, stopped after step 2
upstream without-signal: started
upstream without-signal: finished all 10 steps
RunWhat the upstream did
With signal: request.signalStopped after step 2 — about one second, the moment curl gave up. ⨯ [ResponseAborted: ] is Next.js noting the abandoned request
Without itRan all ten steps, five seconds of work, for a client that had already gone

Multiply that by every closed tab and every Stop button, and the second version is paying for answers nobody reads.

Where It Matters Most​

  • Proxy routes that forward a request to another API, such as a route between a chat page and an AI agent.
  • Long or expensive calls: AI models, reports, searches, file conversions.
  • Streaming responses: a stream you return has a cancel() hook for the same moment (How to Stream Server-Sent Events from a Next.js Route Handler).

The trusted route in the Microsoft Agent Framework series passes request.signal to its agent call for exactly this reason: a user who presses Stop stops the agent too.

Common Mistakes​

MistakeWhat happensDo this instead
Calling fetch without signalUpstream work continues after the browser leavesfetch(url, { signal: request.signal })
Creating a new AbortController that nothing triggersNothing ever cancels itUse request.signal, or combine it with your own timeout
Treating the abort as a failure to reportLogs fill with errors for every closed tabCheck request.signal.aborted before logging an error

Troubleshooting​

SymptomLikely causeFix
The upstream still finishes its workThe signal isn't passed to the call that does the workPass it to every fetch the request starts
⨯ [ResponseAborted: ] in the server logA client left before the response finishedExpected for abandoned requests
Nothing is cancelled in npm run devThe route was still compiling when the client gave upTry it with npm run build and npm start

FAQ​

Can I add a timeout as well?​

Yes. AbortSignal.any([request.signal, AbortSignal.timeout(10_000)]) gives a signal that fires on whichever comes first: the browser leaving or ten seconds passing.

Does this work for database calls?​

If the database client accepts an AbortSignal, pass it the same way. Otherwise, check request.signal.aborted between steps, as the upstream route above does.

Conclusion​

request.signal fires when the browser's request ends early. Pass it to every fetch your route handler starts, and the upstream call ends with the request — here after one second instead of five, for a client that was already gone.

Additional Resources​