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...
For further actions, you may consider blocking this person and/or reporting abuse
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 ...
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."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" ! ;-)
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.
That sounds correct on all accounts!
One backstop worth pairing with the signal: put the request's deadline into Postgres itself with
SET LOCAL statement_timeoutat 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 withoutreq.signal, the server still kills the query when the budget runs out.SET LOCALresets on commit or rollback, so it doesn't leak to the next request on that pooled connection.Thanks, Robert. I did test
statement_timeoutas the database-side deadline, including the 300 ms case, but I didn't useSET LOCALto scope it to the transaction.The
SET LOCALpart 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 itLOCALalso means the timeout is scoped to that transaction rather than affecting later work on the same connection.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.
Thanks, Dhruv. Agreed, the
pgone 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.writableFinishedwas already true, so the check I use elsewhere couldn't tell that the client had left. Chunking with anawaitgives the event loop a chance to process the disconnect, but you still have to checksignal.abortedbetween chunks, since theawaitalone doesn't stop anything.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.
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
AbortSignalcanβ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. Withpg8.23.1,{ signal }was accepted but didnβt cancel the query, so I cancelled it withpg_cancel_backend()from a separate connection.Your writing style just like wow Amazing!
Thank you so much, Divya! That really means a lot π
Shubhra, I didnβt know that aborting the fetch could still leave the server and Postgres query running π That was really interesting!
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! π
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?
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!