Back to News & Insights
JavaScript October 6, 2026 · 8 min read

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

I fired 20 requests at a Node.js handler and made every client walk away after 100 ms. The handler...

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

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.

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.

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

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.

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:

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.

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.

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

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation