DEV Community

Cover image for Aborting a Fetch Doesn't Stop Your Node.js Server. Here's What Does.
Shubhra Pokhariya
Shubhra Pokhariya

Posted on AI-assisted

Aborting a Fetch Doesn't Stop Your Node.js Server. Here's What Does.

Surprising pitfalls with Postgres and Node signals

I fired 20 requests at a Node.js handler and made every client walk away after 100 ms. The handler was five steps of 200 ms each, so 100 steps of work in total if nothing got cancelled.

Nothing got cancelled. The server finished 100 of 100 steps for clients that were already gone.

Then I taught the handler to notice the disconnect. Same 20 abandoned requests, and this time it ran 0 of 100 steps.

If your frontend calls controller.abort() when a user switches a filter, your backend may be doing the same thing right now. The client-side abort is real, but it doesn't automatically reach the work your server is doing. I wanted to know exactly where it ends, so I tested node:http, Express 4 and 5, Hono and a real PostgreSQL database. Node 22.22.2 and 24.21.0, pg 8.23.1, PostgreSQL 18.4, HTTP/1.1, everything on localhost. I used Node's built-in fetch as the client, not a browser.

Three results surprised me:

  • node-postgres accepted a { signal } option and silently ignored it.
  • req.signal exists on Node 24 and is undefined on Node 22, with no warning.
  • Sending the Postgres cancel through your normal pool can hang it.

Where the cancellation stops

client        abort() -> fetch rejects, connection closes
server        sees a closed socket
your handler  keeps running
fetch to      keeps running
another API
SQL query     keeps running
Enter fullscreen mode Exit fullscreen mode

Only the first two lines happen on their own. Everything below them is code you have to write. A synchronous CPU loop is a special case, and I'll get to it near the end.

What the server sees when a client aborts

I wrote a handler with five 500 ms steps and aborted from the client at 1200 ms:

[1216ms] client: abort()
[1223ms] client: caught AbortError
[1225ms] server: res 'close' (writableEnded=false, destroyed=true)
[1548ms] server: step 3/5 done (socket.destroyed=true)
[2562ms] server: step 5/5 done (socket.destroyed=true)
[2567ms] server: res.end() called, no error thrown
Enter fullscreen mode Exit fullscreen mode

The client was done in 7 ms. The server noticed something, because the response emitted close. But the handler is just an async function, and nobody told it to stop. Steps 3 to 5 ran against a dead socket, and res.end() didn't even throw.

How to detect a client disconnect in Node.js

Listen for close on the response. The Node http docs say ServerResponse emits it when the response finishes or when the connection drops before that. Inside the handler, check res.writableFinished to tell the two cases apart. It was true after a normal response and false after an abort.

I skip the request's close event. It also fired on disconnect in my runs, but the docs only promise it means the request was completed, and I don't want to build on something that isn't guaranteed.

This is the helper I ended up with. It merges "the client left" with a server-side deadline:

export function requestSignal(res, { timeoutMs } = {}) {
  const gone = new AbortController();
  res.once("close", () => {
    if (!res.writableFinished) gone.abort(new Error("client closed request"));
  });
  const signals = [gone.signal];
  if (timeoutMs) signals.push(AbortSignal.timeout(timeoutMs));
  return AbortSignal.any(signals);
}
Enter fullscreen mode Exit fullscreen mode

Pass the result to anything that takes a signal:

const signal = requestSignal(res, { timeoutMs: 600 });
const r = await fetch(upstreamUrl, { signal });
Enter fullscreen mode Exit fullscreen mode

When the client left at 200 ms, my API stopped and the downstream request closed early. With the 600 ms deadline, the client got a 504 and the downstream request closed early too. signal.reason tells you which case you're in: a TimeoutError for the deadline, or your own error for a disconnect. In an earlier run with a hand-wired controller, the cancel travelled from the client through the API to the downstream service in about 25 ms.

Two details that cost me time:

  • Create the timeout signal inside the request. AbortSignal.timeout() starts counting when you call it, so a module-level one expires once and then fails every request.
  • AbortSignal.timeout() rejects with TimeoutError, not AbortError. If your catch only checks for AbortError, timeouts slip through. Check e.name.

AbortSignal.any needs Node 20.3 or 18.17, and AbortSignal.timeout needs 17.3.

Does Express have req.signal? It depends on your Node version

Recent Node versions give IncomingMessage a built-in signal. Here is what I saw:

  • Node 22.22.2, node:http, Express 4.22.3 and 5.2.1: req.signal is undefined.
  • Node 24.21.0, same setups: it exists, and the handler stopped when the client left.
  • Hono 4.13.12 with @hono/node-server 2.1.3: c.req.raw.signal fired on both Node versions.

The Node docs list req.signal as added in 24.16.0 and 26.1.0. There is a catch for the versions right after that. On 24.16 to 24.19 and 26.1 to 26.6, the signal can abort after a perfectly normal request finishes, which makes it unreliable for spotting a real disconnect. The fix landed in 24.20.0 and 26.7.0. If you run something in between, upgrade or test it yourself.

The Node 22 case is the sneaky one. sleep(ms, null, { signal: req.signal }) passes undefined, runs without any cancellation and throws nothing. The res 'close' helper above behaves the same on every version I tried, which is why I kept using it.

Does aborting a fetch stop a running Postgres query?

No. I ran select pg_sleep(3) in a handler, aborted the client at 500 ms, and asked Postgres from a second connection what was still running:

[ 917ms] api: client left
[1227ms] DB: active pg_sleep queries 300ms after client left = 1
[3494ms] api: query finished normally
Enter fullscreen mode Exit fullscreen mode

Postgres kept working for about 2.6 seconds after the client left. With a real report query, that means a held connection, wasted CPU and sometimes held locks.

node-postgres ignores the signal option

The API shape makes this tempting:

await client.query({ text: "select pg_sleep(2)", signal });
Enter fullscreen mode Exit fullscreen mode

With pg 8.23.1, the query ran for 2009 ms and nothing was thrown. I also searched the installed pg, pg-pool and pg-protocol code and found no AbortSignal handling. Proposals exist in the node-postgres repo, but none of that was in the version I tested. So this is about pg 8.23.1 only. It fails silently, so you only catch it by testing.

How to cancel a Postgres query with pg_cancel_backend

What worked was asking Postgres to cancel the running statement from a different connection, because the busy one is stuck inside the query:

// the cancellation part only, not a complete handler
const signal = requestSignal(res, { timeoutMs: 5000 });
const client = await pool.connect();
const { rows: [{ pid }] } = await client.query("select pg_backend_pid() as pid");

signal.addEventListener(
  "abort",
  () => cancelPool.query("select pg_cancel_backend($1)", [pid]).catch(console.error),
  { once: true }
);

await client.query("select pg_sleep(3)"); // rejects with code 57014 when cancelled
Enter fullscreen mode Exit fullscreen mode
[5256ms] api: pg_cancel_backend sent
[5257ms] api: query error -> 57014 canceling statement due to user request
[5570ms] DB: active pg_sleep queries 300ms after client left = 0
Enter fullscreen mode Exit fullscreen mode

I left the rest of the handler out on purpose, because a half-finished copy of it is worse than none. The full handler, plus the Node 22 vs 24 screenshots, is in my longer write-up. The version I ran on Node 22 and 24 with Express 4.22.3 and 5.2.1 also does these things:

  • It uses a separate pool for cancels. With a work pool of 2 and two abandoned requests at once, I reproduced a hang. Both connections were held by handlers waiting on a cancel that needed a free connection.
  • It checks signal.aborted right after the pid query. If the deadline already fired, the listener above never runs. The client is still waiting, so the handler answers 504 itself.
  • It removes the listener and awaits any in-flight cancel before client.release(). Otherwise the connection can go back to the pool mid-cancel.
  • It catches 57014 and reads signal.reason. That code only means "cancelled". A deadline, a disconnect, statement_timeout or a DBA can all cause it, and the answer should be 504 only for the deadline.
  • It replies only if !res.destroyed && !res.writableEnded and sends everything else to next(err), since Express 4 doesn't catch rejected async handlers.
  • Both pools have on("error") handlers. Without them, one dropped idle connection can crash the process.

Treat the cancel as best effort. pg_cancel_backend returning true means the signal was sent. The Postgres protocol docs say a cancellation might or might not have any effect, for example when it lands after the query already finished, and the client can't directly tell which happened. With a pool, only cancel while your request still owns the connection. It also does nothing for a session sitting idle in a transaction, since there is no running statement.

Aborted doesn't mean undone

I ran a transaction that did an INSERT, spent one second on slow work, then COMMIT, and let the client leave at 300 ms. The naive handler committed an order for a user who was long gone. The cancel-aware handler caught 57014 and rolled back, so no row was written. The cancel only interrupts the statement. The rollback is yours to write.

The mirror image happens on the client. If you abort a POST, you can't know whether the server processed it, and a blind retry can create a duplicate. Put idempotency keys on anything with side effects.

You don't have to wait for the client to leave, either. Postgres has statement_timeout, and with 300 ms it raised 57014 after 307 ms. That covers the opposite problem, clients that never disconnect.

The limit: a blocked event loop can't be cancelled

JavaScript can't interrupt code that is already running. I ran a 1000 ms synchronous loop inside a handler and aborted the client at 200 ms. The server couldn't process the disconnect until the loop ended, and by then res.writableFinished was true. Node had written the response into a socket it hadn't yet noticed was closed, so even my own check couldn't tell the client had left.

If the work blocks the loop, there is nothing to cancel. Move it to a worker thread, or cut it into chunks with an await between them.

What I didn't test

Drivers other than pg, HTTP/2, reverse proxies and load balancers (a proxy may or may not tell your upstream that the client left), browsers, Deno and Bun.

Four rules I'd ship with

  1. Treat a disconnect as an event. Listen for close on the response, check writableFinished, and pass one signal to everything that accepts it.
  2. Cancel database work yourself when the driver won't. Check whether yours honors { signal } before you trust it, and keep cancels on their own pool.
  3. Put a deadline on every request with a per-request AbortSignal.timeout() plus statement_timeout.
  4. Make side effects safe. Roll back on cancel and use idempotency keys for retries.

The scripts behind every number are in a GitHub repo, if you want to rerun them.

I only tested pg. If you've checked how mysql2, Prisma or another driver treats an abort signal, tell me what you found in the comments.

Top comments (8)

Collapse
 
leob profile image
leob •

Really cool how you digged into all this and figured out the nitty-gritty! I guess most devs simply don't bother with aborting the server process when the client goes away, although I can imagine that there are cases when it's good not to ignore it ...

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks, leob! Yeah, it’s easy to miss because the client's abort looks like it worked. The server can see the socket close, but that doesn't stop the handler or the work it started.

It matters when the work is expensive or has side effects. In my tests, a pg_sleep(3) query kept running for about 2.6 seconds after the client left, and a naive transaction committed an order for a user who was already gone.

Collapse
 
leob profile image
leob • • Edited

"a naive transaction committed an order for a user who was already gone" - well, if a user fills in $100 in their bank's online web app and clicks "Transfer", but then quickly close the browser, they should be prepared to still see that transaction go through, even when they're no longer "connected" ! ;-)

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya •

Exactly πŸ™‚ For a transfer, you'd want it to go through. In that case, committing is the right call.

What bothered me in my test is that nobody made that call. The handler committed because it never noticed the client was gone, not because it decided to. For anything with side effects, I'd want that choice to be explicit: commit regardless, or roll back on cancel. And use an idempotency key so a retry doesn't create a duplicate.

Thread Thread
 
leob profile image
leob •

That sounds correct on all accounts!

Collapse
 
dhruv_malaviya profile image
Dhruv Malaviya •
node-postgres accepting { signal }
Enter fullscreen mode Exit fullscreen mode

and ignoring it is the most dangerous finding , an option that's accepted and does nothing is worse than one that throws, because the types and the runtime agree you did the right thing.

The Postgres cancel hanging through the pool is worth explaining: a cancel needs its own connection, and a saturated pool means it waits behind the work it's trying to stop. Cancels want a dedicated connection.

And bluntly , you cannot cancel running JavaScript. A tight loop is uncancellable by construction and needs explicit checkpoints.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks, Dhruv. Agreed, the pg one is the finding I'd most want people to check in their own stack, at least on 8.23.1, which is the version I tested. An option that throws is obvious. An option that's accepted but doesn't cancel anything only shows up when a query you thought had stopped is still running in Postgres.

The pool issue played out exactly as you described. In my repro, the work pool had 2 connections and two requests were abandoned at the same time. Both handlers held their connections while waiting for the cancel, but the cancel needed a free connection from that same pool, so nothing moved. That's why the full handler uses a separate small pool for cancels.

And yes on the loop. My 1000 ms synchronous loop meant the server couldn't process the disconnect until the loop finished. By then res.writableFinished was already true, so the check I use elsewhere couldn't tell that the client had left. Chunking with an await gives the event loop a chance to process the disconnect, but you still have to check signal.aborted between chunks, since the await alone doesn't stop anything.

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala •

Shubhra, I didn’t know that aborting the fetch could still leave the server and Postgres query running πŸ˜… That was really interesting!