I let 12 Node.js clients walk away from their database queries on a 4-connection pool. The abandoned queries kept every connection busy for about 6 seconds, and the first healthy select 1 waited 5.7 seconds behind them. Passing an AbortSignal to the query did nothing for pg, mysql2 or mariadb, and postgres.js 3.4.9 has no such option.
This is the second post in my series on cancellation in Node.js. In the first, I tested whether aborting a request stops the work behind it (read it here). With node-postgres it doesn't. The query ran to completion, and pg_cancel_backend was what stopped it. That left two questions. Do other drivers behave differently? And what does a pile of abandoned queries do to everyone else who shares the pool?
I built a harness to answer both, and the postgres.js results did not go the way I expected. My first postgres.js starvation run came back with four cancelled promises that never settled, and my harness threw the run away as invalid. I changed it to count unsettled requests instead of discarding them, then cut the problem down to a script that does not use the harness at all. The result held. Then postgres.js 4.0.0 came out with a new query.signal(), so I ran everything again against both versions.
Both tests cover pg, postgres.js 3.4.9 and 4.0.0, mysql2 and the mariadb connector, against PostgreSQL 18.6 and MySQL 8.4.13.
TL;DR
- Of the five driver versions I tested, only postgres.js 4.0.0 reads an
AbortSignalon a query (query.signal()). pg 8.23.1, mysql2 3.24.6, mariadb 3.5.4 and postgres.js 3.4.9 do not. I searched the installed packages, then checked every result against the database, not against what the driver reported. - In the naive lock pile-up test, 12 abandoned requests kept a 4-connection pool busy. The first healthy
select 1waited about 5.7 seconds, and the database showed about 24 connection-seconds of tagged query activity. - Cancelling the requests that were running and skipping the ones that had not started kept the first healthy query at 1 to 107 ms on Windows and 0 to 1 ms on Linux with pg, mysql2 and mariadb. postgres.js 4.0.0 with
query.signal()did it in 1 to 3 ms, in a test where every query was blocked on the same lock. A fast query already sent behind a running one was not rejected at the abort, though (see the postgres.js section). - postgres.js 3.4.9 has a trap. With a warm pool, 4 of the 12 cancelled promises never settled, in every run I made, on Windows and on Linux. How much that hurt other queries varied from run to run. I did not reproduce the unsettled promises on 4.0.0 in any scenario I tested. On 3.x, reserving a connection per request avoided them in my test.
Here is what to do for each driver, based on these tests.
| Driver | Reads an AbortSignal? |
What stopped the query |
|---|---|---|
| pg 8.23.1 | No |
pg_cancel_backend() from a second connection |
| mysql2 3.24.6 | No |
KILL QUERY from a second connection |
| mariadb 3.5.4 | No |
KILL QUERY from a second connection |
| postgres.js 3.4.9 | No such option |
query.cancel(), but 4 of 12 promises never settled with a warm pool. A reserved connection per request avoided that |
| postgres.js 4.0.0 | Yes, query.signal()
|
query.signal(signal). All 12 queries in my lock test were rejected, but a fast query already sent behind a running one was not rejected at the abort |
How I measured it
Verdicts and busy-connection counts come from the database side. A separate observer connection polls pg_stat_activity (PostgreSQL) or SHOW FULL PROCESSLIST (MySQL) for a query tagged with a unique comment. It polls every 50 ms in test 1 and is sampled every 100 ms in test 2, so those timings have about that much resolution. Wait times and whether a promise settled are client-side timers, because that is where they happen. I never rely on what a driver says about itself.
I ran everything on Windows 10 with Docker Desktop (WSL 2), Node.js 22.22.2, PostgreSQL 18.6 and MySQL 8.4.13. All connections are direct and on localhost. I also ran both tests once on each of Node.js 22.23.4 and 24.22.1 on GitHub Actions, on an Ubuntu runner against the same PostgreSQL 18.6 and MySQL 8.4.13 images. Those runs are on the Actions tab of the repo, and run #1 is the one quoted here.
I ran two tests and two small reproduction scripts. The code is in the repo, node-db-abort-matrix.
Test 1: does passing an AbortSignal cancel a database query?
A query sleeps for 3 seconds on the server. The client waits until the database shows it running, aborts 500 ms later, and immediately sends select 1 through the same one-connection pool. Each driver runs up to three ways: no cancellation, the signal passed as a query option (where the driver has one), and the driver's own cancel route. A query counts as stopped if the database is idle within 1 second of the abort.
pg, postgres.js 3.4.9, mysql2 and mariadb ran this nine times on Windows, and postgres.js 4.0.0 ran three times.
| Driver | Version | Passing a signal | Cancel that worked | Time to stop after cancel |
|---|---|---|---|---|
| pg | 8.23.1 | Ignored. Database idle 2.5 s after the abort |
pg_cancel_backend() from a second connection |
4 to 136 ms |
| postgres.js | 3.4.9 | No such option | query.cancel() |
61 to 88 ms |
| postgres.js | 4.0.0 | Honored. query.signal(signal) stopped the query, database idle 64 to 88 ms after the abort |
query.cancel() |
64 to 65 ms |
| mysql2 | 3.24.6 | Ignored. Database idle 2.5 s after the abort |
KILL QUERY from a second connection |
3 to 116 ms, one run 651 ms |
| mariadb | 3.5.4 | Ignored. Database idle 2.5 s after the abort |
KILL QUERY from a second connection |
3 to 71 ms |
All verdicts were identical in every Windows run. Of the 39 cancel runs, 38 stopped within 136 ms. One mysql2 run took 651 ms, and I read nothing into a single outlier. The two Linux runs, one per Node version, gave the same verdicts for all five driver versions. postgres.js 4.0.0's .signal() stopped the query on both, with the database idle 54 ms after the abort.
When the signal was ignored, the select 1 behind it waited 2.4 to 2.5 seconds, because the connection was still running the sleep.
I also searched the installed packages for signal and abort in their JavaScript, TypeScript and Markdown files. In pg, mysql2 and mariadb, the only hits were error-code tables, comments and a pool message. postgres.js 3.4.9 and pg-pool (3.14.0) had no hits at all. For mariadb I passed both signal and abortSignal, so the result does not depend on guessing the option name. postgres.js 4.0.0 is the exception, and its release notes list .signal(abortSignal) as new.
One MySQL detail to know. When KILL QUERY interrupts SELECT SLEEP(n), MySQL returns 1 and no error, so the promise resolves early instead of rejecting. A killed lock wait behaves differently. It rejected with ER_QUERY_INTERRUPTED in both mysql2 and mariadb (see test 2).
Test 2: how abandoned requests starve a Node.js connection pool
This is the incident that makes teams care. One session holds a row lock for 6 seconds. Twelve requests that need that row go through a pool of 4 connections, and every client gives up 200 ms later. Starting 300 ms in, a healthy select 1 is sent every 100 ms through the same pool. A healthy query counts as slow if it takes longer than 500 ms or fails.
Each driver runs up to three times:
- naive: nothing notices the clients left.
- signal: the signal is passed to the driver and nothing else is done. This is what you get if you trust the option.
- cancel-aware: for pg, mysql2 and mariadb, a request that has not started is skipped, and one that is running is cancelled with the route from test 1. The postgres.js adapters send all 12 queries to the pool at once and cancel all 12, so nothing is skipped there.
On Windows I ran naive and cancel-aware six times and the signal mode three times. postgres.js 4.0.0 ran three times in every mode. The ranges below are across those runs.
| Driver | Mode | First healthy query waited | Slow healthy queries (of about 58) | Connection-seconds held | Abandoned: rejected / finished or skipped / never settled |
|---|---|---|---|---|---|
| pg | naive | 5709 to 5743 ms | 49 | 23.7 to 24.0 | 0 / 12 / 0 |
| pg | signal | 5731 to 5743 ms | 49 to 50 | 24.0 to 24.1 | 0 / 12 / 0 |
| pg | cancel-aware | 2 to 6 ms | 0 | 0.4 to 0.8 | 4 / 8 / 0 |
| postgres.js 3.4.9 | naive | 5728 to 5757 ms | 48 to 49 | 23.5 to 23.8 | 0 / 12 / 0 |
| postgres.js 3.4.9 | cancel-aware | 5704 to 5713 ms | 2 to 48 | 12.7 to 24.1 | 8 / 0 / 4 |
| postgres.js 4.0.0 | naive | 5714 to 5761 ms | 48 to 49 | 23.6 to 23.7 | 0 / 12 / 0 |
| postgres.js 4.0.0 | signal | 2 to 3 ms | 0 | 1.4 | 12 / 0 / 0 |
| postgres.js 4.0.0 | cancel-aware | 2 to 3 ms | 0 | 1.3 to 1.4 | 12 / 0 / 0 |
| mysql2 | naive | 5702 to 5818 ms | 47 to 50 | 23.6 to 24.5 | 0 / 12 / 0 |
| mysql2 | signal | 5707 to 5719 ms | 48 to 49 | 23.6 to 24.1 | 0 / 12 / 0 |
| mysql2 | cancel-aware | 2 to 107 ms | 0 | 0.6 to 1.4 | 4 / 8 / 0 |
| mariadb | naive | 5707 to 5735 ms | 48 to 49 | 23.7 to 23.9 | 0 / 12 / 0 |
| mariadb | signal | 5692 to 5711 ms | 47 to 48 | 23.5 to 23.6 | 0 / 12 / 0 |
| mariadb | cancel-aware | 1 to 5 ms | 0 | 0.4 to 0.9 | 4 / 8 / 0 |
Connection-seconds is the number of the test's tagged queries that the database shows as active, summed over time and sampled every 100 ms. It is not how long a client library considers a connection checked out.
A few notes on reading the table.
- The naive numbers are nearly identical across drivers because the pool and the lock decide them, not the driver. Nobody gets a connection until the lock holder lets go. The 5.7 seconds is my 6-second lock minus the 300 ms before the first probe. A longer lock means a longer stall.
- The signal rows for pg, mysql2 and mariadb look like naive, because those drivers ignore the option. Passing
{ signal }and walking away fixed nothing. - In cancel-aware mode for pg, mysql2 and mariadb, 4 requests were running when the clients left, so they needed a cancel. The other 8 were still waiting for a connection and never needed to run. Skipping them is half of the saving, and a cancel that fires before the statement starts does nothing, so check
signal.abortedafter you get the connection. - The postgres.js adapters have no "not started" state for my code to check, so they send all 12 queries and call
query.cancel()on all 12 after 200 ms. On 4.0.0 all 12 were rejected with57014. On 3.4.9, 8 were rejected and 4 never settled. - Killed lock waits rejected with
57014on PostgreSQL andER_QUERY_INTERRUPTEDon MySQL, so a cancel works on a blocked query as well as a running one.
The Linux runs, one on Node 22 and one on Node 24, show the same pattern. The naive and signal rows for pg, mysql2 and mariadb had the first healthy query waiting 5700 to 5705 ms. Cancel-aware took 0 to 1 ms for those three drivers, with 0.5 to 0.7 connection-seconds held. postgres.js 4.0.0 took 1 ms in both its signal and cancel-aware modes, with 0.7 to 1.3 connection-seconds held.
The postgres.js 3.4.9 cancel-aware row needs a closer look. Exactly 4 promises never settled in every run I made, six on Windows and one on each Node version on Linux. The other numbers moved a lot. Across the Windows runs, between 2 and 48 healthy queries were slow, and both Linux runs gave 2 slow out of the 62 healthy queries each run sent. The first healthy query waited about 5.7 seconds in every Windows run and in the Node 24 run on Linux, but only 1 ms in the Node 22 run. I have not traced why. Treat the unsettled promises as the result and the stall as timing dependent.
postgres.js 3.4.9: queries that never settle, and what 4.0.0 changed
In test 2, postgres.js 3.4.9 is the exception. In every run, 4 abandoned promises never settled. In my Windows runs the first healthy query also waited 5.7 seconds, and the paragraph above shows how much that part varied.
I reduced it to a script that does not use my harness. Twelve queries wait on a row lock, the pool has 4 connections, and query.cancel() is called on all of them after 200 ms. I ran it four times on 3.4.9 and once on 4.0.0.
| Scenario | 3.4.9 before the cancel | 3.4.9 result | 4.0.0 result |
|---|---|---|---|
| Cold pool, 12 queries | 4 running, 8 queued inside the pool | all 12 rejected with 57014
|
all 12 rejected with 57014
|
| Warm pool, 12 queries | 4 running, 8 already sent and waiting behind a running query | 4 never settled (always queries 4 to 7) | all 12 rejected |
| Warm pool, 8 queries | 4 running, 4 sent and waiting | all 8 rejected | all 8 rejected |
Warm pool, 12 queries, max_pipeline: 1
|
4 running, 4 sent, 4 queued | all 12 rejected | all 12 rejected |
| Warm pool, 12 requests, one reserved connection each | 4 running, 8 waiting for a connection | 4 rejected, 8 skipped, none stuck | 4 rejected, 8 skipped, none stuck |
The 3.4.9 runs all gave the same result as the table. How the cold pool looks just before the cancel depends on how fast connections open. It is usually 4 running and 8 queued, and once, on a slow start, all 12 were still queued. The verdict was the same. 4.0.0 schedules queries differently, so two of its scenarios start from a different state. The cold pool already had 8 queries sent behind the 4 running ones, and with max_pipeline: 1 the 8 extra queries waited inside the pool. Nothing was left unsettled in any scenario.
On 3.4.9 the problem needs a warm pool, and the stuck requests were among those already sent to a connection behind a running query. Not every request in that state got stuck, though. Four of the 8 did. With one waiting query per connection, nothing got stuck.
Issue #1252 in the postgres.js repo describes what I measured. Cancelling a query that is pipelined behind another one but has not started leaves the cancelled query unsettled, and the next query on that connection can get the wrong result. A comment in the thread points at the loop in ReadyForQuery in connection.js (lines 573 and 574 in 3.4.9). It was closed by a v4 commit that shipped in 4.0.0.
The issue also describes a worse effect. The next query on that connection can receive the cancelled statement's rows. My harness counted failed probes as slow but did not check what the probes returned, so I can't say whether that happened in my runs.
4.0.0 also adds query.signal(signal). Per its release notes, aborting rejects a query that is still waiting and cancels one that is running. That is consistent with the table above, where all 12 queries were blocked on the same lock and all 12 were rejected. The next section checks what "waiting" means.
When .signal() rejects a query, and when it does not
"Waiting" can mean two different things in postgres.js. A query can wait in the pool's queue, or it can already be sent to a connection behind a running query. I wrote a second script to tell them apart (src/repro-postgresjs4-signal.js). The pool has one connection. A slow query runs for 1.5 seconds, a second query is behind it, and the second query's signal is aborted 200 ms in. I ran four cases once on Windows against PostgreSQL 18.6.
| Case | Second query | Result |
|---|---|---|
| A: sent behind the running query (default), fast | select 'fast' |
resolved normally, 1668 ms after the start, so the abort did not reject it |
| B: sent behind the running query (default), slow | select pg_sleep(5) |
rejected with 57014 at 1532 ms, when the query ahead had finished |
C: queued in the pool (max_pipeline: 1), fast |
select 'fast' |
rejected with 57014 at 223 ms |
D: queued in the pool (max_pipeline: 1), slow |
select pg_sleep(5) |
rejected with 57014 at 220 ms |
So .signal() rejected a query promptly while it was still queued inside the pool. A query that had already been sent behind a running one, which is the default, was not rejected at the abort. A slow one was rejected once the query ahead finished, and a fast one ran to completion and resolved normally. In the lock test all 12 queries were blocked on the same lock, which fits each one being cancelled when its turn came, but I did not trace that. I did not test writes. This is one run of mine, so read the order of events and not the milliseconds.
Every cancel in postgres.js is best effort, including on 4.0.0. Its README explains that a cancel opens a new connection and sends a protocol-level cancel request, so there is no guarantee the query stops, and because of race conditions it can end up cancelling a different query. The 4.0.0 release notes say the same. A cancel that reaches the server just as its query finishes can still stop the next query on that connection. I did not test for that race.
If you are on postgres.js 3.x and cancel bursts of queries, reserving a connection per request is what I would use, and it is the second postgres.js recipe below. If you can move to 4.0.0, .signal() is simpler. It is a major release with behavior changes (parameter typing, supported runtimes, transaction handling), so read its upgrade guide first.
How to cancel a database query in Node.js when the driver ignores AbortSignal
The shape is the same everywhere. Take a connection, check that the client is still there, remember which backend you are on, and cancel from somewhere else if the client leaves. These follow the adapters in the repo, which are what I measured. There are two differences. The repo sends cancels over a single dedicated connection, one at a time, and the node-postgres version below uses a small pool for that. And the repo ignores a failed cancel, because a cancel that did not work shows up in the measurement as a query that kept running, while the recipes below log it, which is what you want in production.
node-postgres
// cancel(pid) sends pg_cancel_backend from a small pool kept only for cancels
const cancel = (pid) => cancelPool.query("select pg_cancel_backend($1)", [pid]);
export async function cancellableQuery(pool, text, params, signal) {
const client = await pool.connect();
let cancelling = Promise.resolve();
try {
if (signal.aborted) return null; // the client left while we waited for a connection
const { rows } = await client.query("select pg_backend_pid() as pid");
const pid = rows[0].pid;
if (signal.aborted) return null;
const onAbort = () => {
cancelling = cancel(pid).catch((err) =>
console.error("cancel failed", err),
);
};
signal.addEventListener("abort", onAbort, { once: true });
try {
return await client.query(text, params);
} finally {
signal.removeEventListener("abort", onAbort);
await cancelling;
}
} finally {
client.release();
}
}
mysql2 (the mariadb connector needs two changes. Its query() returns the rows directly, so write const rows = await conn.query(...) and not const [rows] = ..., and connection_id() comes back as a BigInt, so keep the Number())
// killConn is a separate connection used only for KILL QUERY
export async function cancellableQuery(pool, killConn, sql, signal) {
const conn = await pool.getConnection();
let killing = Promise.resolve();
try {
const [rows] = await conn.query("select connection_id() as id");
const threadId = Number(rows[0].id);
if (signal.aborted) return null; // the client left while we waited for a connection
const onAbort = () => {
killing = killConn
.query(`KILL QUERY ${threadId}`)
.catch((err) => console.error("cancel failed", err));
};
signal.addEventListener("abort", onAbort, { once: true });
try {
return await conn.query(sql);
} finally {
signal.removeEventListener("abort", onAbort);
await killing;
}
} finally {
conn.release();
}
}
postgres.js 4.0.0
export async function cancellableQuery(sql, text, signal) {
// rejects a query waiting in the pool queue and cancels a running one.
// A query already sent behind a running one is not rejected at the abort.
return await sql.unsafe(text).signal(signal);
}
postgres.js 3.x (one reserved connection per request, the shape of the last scenario above)
export async function cancellableQuery(sql, text, signal) {
const reserved = await sql.reserve();
try {
if (signal.aborted) return null; // the client left while we waited for a connection
const query = reserved.unsafe(text);
const onAbort = () => query.cancel();
signal.addEventListener("abort", onAbort, { once: true });
try {
return await query;
} finally {
signal.removeEventListener("abort", onAbort);
}
} finally {
await reserved.release();
}
}
These recipes have one limit. Checking the signal after you get a connection stops an abandoned request from sending its SQL, but it does not cancel the wait for the connection itself. A request whose client leaves while it waits in pool.connect() or getConnection() stays queued until the pool hands it a connection. The check then releases that connection without running the query. That is what happened to the 8 queued requests in test 2. postgres.js 4.0.0's .signal() differs here. It rejected a query waiting in the pool queue, though not one already sent behind a running query (see above).
Query builders and ORMs have started exposing database-side cancellation too. Kysely 0.29.0 and MikroORM 7.1 both accept an AbortSignal and offer an inflightQueryAbortStrategy option with three values: 'ignore query' (the default), 'cancel query' and 'kill session'. Their APIs differ, but the distinction is the same. The Kysely API docs say the default stops waiting while the query keeps running on the server, and the connection goes back to the pool only after the query settles. That is the pool problem from test 2. 'cancel query' uses pg_cancel_backend on PostgreSQL or KILL QUERY on MySQL, and the Kysely 0.29.0 release notes show an optional controlClient for sending that cancel over a connection outside the pool, which is rule 3 below. MikroORM's query cancellation docs list the same three strategies. I tested none of these layers, so check them the way I checked the drivers, from the database side.
Here are the rules I would keep.
- Check
signal.abortedafter you get the connection and before you send the query. A cancel that arrives before the statement starts does nothing. postgres.js 4.0.0 covers only part of this. In a one-connection check,.signal()rejected a query queued in the pool (max_pipeline: 1) after about 0.2 s. A query already sent behind a running one was not rejected at the abort. A slow one was rejected when the query ahead finished, and a fast one still ran to completion. - Read the pid or connection id on the connection that runs the query, in the same checkout, and only where one pool checkout keeps one database backend. That holds for a direct connection. It does not hold behind a transaction-mode pooler such as PgBouncer. In a quick check I ran earlier (PgBouncer 1.22.0, transaction mode, pool size 2, PostgreSQL 16, Node 22 and pg 8.23.1, all on localhost, with the details in a comment on my first post), reading the pid with a plain
select pg_backend_pid()and then running the query as a separate statement was not safe. The pid changed between statements, and cancelling the stored pid cancelled another client's query with57014. Inside an explicit transaction the pid stayed the same in my run. I did not test cancel pile-ups behind a pooler, and the tests in this post used direct connections. - Send cancels from a separate connection or a small dedicated pool. On pg 8.23.1, queueing several queries on one
Clientprints a deprecation warning that says the behavior will be removed inpg@9.0. My harness hit it by sending several cancels over one connection at once, so it now sends them one at a time. - Treat
57014andER_QUERY_INTERRUPTEDas the expected result of a cancel, not as errors worth paging on, but do not read them as proof that your client cancelled. They only say a statement was cancelled. In PostgreSQL,statement_timeoutand a DBA runningpg_cancel_backendproduce the same SQLSTATE57014. In MySQL, aKILL QUERYthat interrupts a lock wait produces the sameER_QUERY_INTERRUPTEDerror code. Checksignal.abortedbefore you decide who cancelled. - Check the permissions of the account that sends the cancel. In PostgreSQL,
pg_cancel_backendworks on backends of your own role or of roles you are a member of.pg_signal_backendextends that, and only superusers can cancel superuser backends. In MySQL,KILL QUERYworks on your own threads without extra privileges, and killing other accounts' threads needsCONNECTION_ADMINorSUPER. My cancels run as the same user as the query. If yours run as a different account, test it.
A timeout is not the same as noticing that the client left. statement_timeout ends a statement after a fixed time whether or not anyone is still waiting for it, so you choose between cutting off slow queries that someone wants and leaving abandoned ones running. lock_timeout does the same for lock waits. Both are worth having as a backstop behind the cancel. Inside a transaction, SET LOCAL statement_timeout scopes the limit to that transaction, which is why it suits a transaction-mode pooler better than a session-level SET. None of these were part of this post's tests. I only tried a plain statement_timeout in my first post, and I did not test lock_timeout or SET LOCAL.
What I did not test
- A full PgBouncer or RDS Proxy matrix. Cancelling by backend pid depends on holding the connection, and a pooler can break that. My only PgBouncer check is the quick one in rule 2, and it used
pg. - Prisma, Drizzle, Kysely, MikroORM and other layers on top of these drivers. Kysely and MikroORM have their own
AbortSignalsupport, described above, and I tested none of it. - Real CPU-bound queries.
pg_sleep,SLEEP()and lock waits are easy to interrupt, and a long sort or aggregate may react differently. - Serverless drivers, remote databases and network latency. Everything here is localhost.
- Other pool sizes, longer locks and other numbers of abandoned requests.
- MariaDB Server. The mariadb connector was tested against MySQL 8.4.13.
- Writes and other pool sizes in the
.signal()ordering check, and postgres.js 4.0.0's cursor and transaction changes. Its release notes describe those changes, and I did not test them. - The recipes above are the patterns the tests ran, not a load-tested library.
node-postgres has pull requests that propose abortable queries, one with a signal option for queries (#3212) and a newer one for abortable query cancellation (#3751). #3212 was still open when I checked.
Run it yourself
You need Node.js 22 and Docker. The tests create a table named abortlab_lock, hold a row lock on it for 6 seconds and kill queries. Run them only against the throwaway databases that npm run up starts, which are bound to 127.0.0.1. Never point them at a database you care about. npm run down removes the containers and their data.
npm ci
npm run up
npm start
npm run starvation
npm start writes the test 1 table to results/matrix.md, and npm run starvation writes the test 2 table to results/starvation.md. The postgres.js reproduction is node src/repro-postgresjs-cancel.js. To run it against 4.0.0, pass the package name, as in node src/repro-postgresjs-cancel.js postgres4. The .signal() ordering check is node src/repro-postgresjs4-signal.js. Every Windows range in this post comes from node scripts/summarize-results.js, which recomputes them from the saved files in results/. The Linux numbers come from run #1 of the GitHub Actions workflow. Adding a driver is one file of about 50 lines, described in CONTRIBUTING.md.
If you run it against PgBouncer, RDS Proxy or a driver I left out, I would like to see your table.
Top comments (4)
In pg 8.23.1, mysql2 3.24.6 and mariadb 3.5.4, passing an
AbortSignaldidn't cancel the query. What actually stopped it was a cancel sent from a second connection. postgres.js 4.0.0 was the only version I tested that reads the signal, but even there, a fast query already sent behind a running one wasn't rejected when the signal was aborted.I haven't tested poolers beyond one PgBouncer check. In transaction mode, a stored backend pid ended up cancelling another client's query, and I haven't gone further than that.
If you've run into this behind PgBouncer or RDS Proxy, or handled cancellation through Prisma, Drizzle or Kysely, I'd really like to hear what you found.
Shubhra, another interesting experiment š I honestly wouldn't have thought about what happens to those database queries after a client disconnects. Loved this one!
Thank you, Hema! š I actually wanted to find out what happens in exactly this situation. When a client-side request is aborted, you'd expect the database work to stop too, but that isn't always what happens. In several of the drivers I tested, queries kept running and held connections that other requests needed. I'm really glad you enjoyed reading it!
The key operational question is what happens after the caller leaves. Iād track cancellation propagation, connection release time, server-side query lifetime, and pool saturation separately; a canceled HTTP request is not proof that the database stopped doing work. Those measurements make driver behavior actionable for capacity planning.