DEV Community

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

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

Shubhra Pokhariya on October 06, 2026

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 w...
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
 
robertpaschal profile image
Odinaka Robert Nnamani •

One backstop worth pairing with the signal: put the request's deadline into Postgres itself with SET LOCAL statement_timeout at the start of the transaction. If the cancel path fails, whether from the ignored { signal }, a hung cancel through the pool, or a Node version without req.signal, the server still kills the query when the budget runs out. SET LOCAL resets on commit or rollback, so it doesn't leak to the next request on that pooled connection.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks, Robert. I did test statement_timeout as the database-side deadline, including the 300 ms case, but I didn't use SET LOCAL to scope it to the transaction.

The SET LOCAL part is a useful addition. The application-side signal handles the cancellation path when the client leaves or the request deadline expires, while Postgres still has its own deadline if that path fails or the driver ignores { signal }. Keeping it LOCAL also means the timeout is scoped to that transaction rather than affecting later work on the same connection.

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
 
shieldxbot profile image
shieldx •

Việc hα»§y fetch ở phΓ­a client thα»±c sα»± lΓ  mα»™t hiểu lαΊ§m phα»• biαΊΏn về cΓ‘ch Node.js xα»­ lΓ½ event loop. Nhiều người cα»© nghΔ© request bα»‹ ngαΊ―t lΓ  mọi thα»© dα»«ng lαΊ‘i, nhΖ°ng thα»±c tαΊΏ cΓ‘c tΓ‘c vα»₯ tα»‘n tΓ i nguyΓͺn nhΖ° xα»­ lΓ½ logic nαΊ·ng hay truy vαΊ₯n database vαΊ«n cα»© chαΊ‘y ngαΊ§m cho Δ‘αΊΏn khi hoΓ n tαΊ₯t. MΓ¬nh tα»«ng gαΊ·p rαΊ―c rα»‘i khi cΓ‘c hΓ m async khΓ΄ng được quαΊ£n lΓ½ bαΊ±ng AbortController, dαΊ«n Δ‘αΊΏn tΓ¬nh trαΊ‘ng server bα»‹ nghαΊ½n event loop dΓΉ client Δ‘Γ£ disconnect tα»« lΓ’u. Khi mΓ¬nh lΓ m việc vα»›i cΓ‘c workflow tα»± Δ‘α»™ng hΓ³a bαΊ±ng AI cαΊ§n gọi nhiều API liΓͺn tα»₯c, việc kiểm soΓ‘t timeout vΓ  signal lΓ  bαΊ―t buα»™c để trΓ‘nh lΓ£ng phΓ­ tΓ i nguyΓͺn. MΓ¬nh thường dΓΉng qua labagent dot tech để quαΊ£n lΓ½ cΓ‘c tΓ i khoαΊ£n AI model cho mượt hΖ‘n mΓ  khΓ΄ng lo bα»‹ quΓ‘ tαΊ£i request ngαΊ§m.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks. Small correction on the event loop part. In my tests, the abandoned async work didn’t block it, it just kept running for clients that were already gone (100 of 100 steps finished). The loop only stalls on synchronous CPU work, and an AbortSignal can’t cancel that mid-run.

The server does see the disconnect, since the response emits close, but the handler and the work it started keep going unless you pass a signal down yourself. And that only helps if the thing honors it. With pg 8.23.1, { signal } was accepted but didn’t cancel the query, so I cancelled it with pg_cancel_backend() from a separate connection.

Collapse
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Your writing style just like wow Amazing!

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thank you so much, Divya! That really means a lot 😊

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!

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

The browser stops waiting for the response, but that doesn't automatically stop the work already running on the server or in Postgres. Thanks for reading, Hema! πŸ™‚

Collapse
 
argumentmoney8117 profile image
ArgumentMoney8117 •

Really solid experimental writeup. One question: how does this pattern hold up behind PgBouncer in transaction-pooling mode? The cancel needs a second connection that can reach the same backend, and an abandoned transaction holds a pooled connection until pg_cancel_backend or statement_timeout clears it. Did you test whether slow cancels let abandoned connections pile up and starve the pool?

Collapse
 
_hm profile image
Hussein Mahdi •

The Node 22 req.signal being silently undefined is a scary one. Love that every claim is backed by a runnable test. "Aborted doesn't mean undone" deserves its own poster!