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...
Some comments have been hidden by the post's author - find out more
For further actions, you may consider blocking this person and/or reporting abuse
Go data point for your question: with pgx, Query(ctx, ...) on a canceled context sends a real Postgres cancel request. And net/http cancels the request context automatically when the client disconnects. So the Go equivalent mostly 'just works' as long as you thread ctx through to the driver instead of using context.Background(). One caution on your PgBouncer question: pg_backend_pid is tied to the server-side connection. In transaction pooling mode that connection returns to the pool after COMMIT, so a pid captured during your query can later belong to a different client's session. Canceling by stale pid can kill the wrong query. In pooling mode I'd treat statement_timeout as the real backstop, not pid cancellation.
Thanks, Parsa. I only tested
pg, so I can't speak to pgx.My handler reads the PID with a plain
SELECT pg_backend_pid()and runs the query as a separate statement. In my PgBouncer test, the PID changed between statements, and cancelling with the stored PID cancelled another client's query (57014). Inside an explicit transaction, the PID stayed the same in my run. So I agree PID-based cancellation isn't safe with this setup in transaction-pooling mode.I'd use
statement_timeoutas the backstop, withSET LOCALinside the transaction as Robert suggested above. I haven't tested that setup behind PgBouncer.That 57014-on-the-wrong-client result is exactly the hazard. And your instinct on SET LOCAL is right for a subtle reason: unlike plain SET, SET LOCAL is transaction-scoped, so it's the pooler-safe variant. It can't leak to the next client that gets the connection. In transaction-pooling mode that's the key distinction: session-level SET is the footgun, SET LOCAL is fine. So explicit transaction + SET LOCAL statement_timeout + (optionally) pid-cancel inside that transaction is a coherent, pooler-safe setup. Worth a follow-up post if you test it behind PgBouncer. I'd read that.
Thank you, I really appreciate it, and I'm glad the test was useful. Your explanation of why
SET LOCALis the safe one behind a pooler makes it much clearer for me. I haven't tested the full setup yet, with an explicit transaction,SET LOCAL statement_timeoutand a PID cancel inside it. If I get the chance, I'll write it up as a follow-up. Thanks again for the kind words and the careful comments.glad it landed. the SET LOCAL thing is one of those details that bites you exactly once, then you never forget it.
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.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.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!
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.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?
Thanks. My post didn't cover PgBouncer, so I ran a quick check: PgBouncer 1.22.0 in transaction mode, pool size 2, PostgreSQL 16, Node 22, and
pg8.23.1, all on localhost.Reaching the same backend: this is a limitation of the PID lookup in my handler. It reads the PID with a plain
SELECT pg_backend_pid(), then runs the query as a separate statement. I ran another statement while a second client held that backend, and the second read returned a different PID. Callingpg_cancel_backend()on the stored PID cancelled the other client's query with57014. Inside an explicit transaction, the PID stayed the same in my run. Without PgBouncer, which is what the post tested, the cancel pattern worked.Pile-up: I didn't test slow cancels. I abandoned 2 clients during a single
pg_sleep(4)statement with no cancel, not inside an open transaction. A new request wasn't blocked, but both abandoned queries kept running in PostgreSQL until they finished, about 4 seconds later. With only 2 abandoned requests and no sustained load, I can't say what happens when they pile up.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!
Thanks, Hussein! Yeah, the Node 22
undefinedis the sneaky one, since nothing warns you. And "aborted doesn't mean undone" is the line I'd most want people to keep.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! 🙂
Your writing style just like wow Amazing!
Thank you so much, Divya! That really means a lot 😊