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
 
parsaaftabi profile image
Parsa Aftabi • • Edited

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.

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

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_timeout as the backstop, with SET LOCAL inside the transaction as Robert suggested above. I haven't tested that setup behind PgBouncer.

Collapse
 
parsaaftabi profile image
Parsa Aftabi • • Edited

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.

Thread Thread
 
shubhradev profile image
Shubhra Pokhariya • • Edited

Thank you, I really appreciate it, and I'm glad the test was useful. Your explanation of why SET LOCAL is 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_timeout and 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.

Thread Thread
 
parsaaftabi profile image
Parsa Aftabi • • Edited

glad it landed. the SET LOCAL thing is one of those details that bites you exactly once, then you never forget it.

Collapse
 
dhruonweb 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
 
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
 
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
 
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
 
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
 
shubhradev profile image
Shubhra Pokhariya •

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 pg 8.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. Calling pg_cancel_backend() on the stored PID cancelled the other client's query with 57014. 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.

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!

Collapse
 
shubhradev profile image
Shubhra Pokhariya •

Thanks, Hussein! Yeah, the Node 22 undefined is the sneaky one, since nothing warns you. And "aborted doesn't mean undone" is the line I'd most want people to keep.

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
 
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 😊

Some comments have been hidden by the post's author - find out more