Skip to content

Build1 publisher2 min readPublished

postgres-js pads pooled query results with empty slots after a mid-stream timeout

postgres-js leaves a connection's row cursor set after a query fails mid-stream, so one team's LIMIT 500 query came back with 813 entries. With the one-line fix an open pull request since October 2025, pooled users have to patch the driver themselves.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying postgres-js pads pooled query results with empty slots after a mid-stream timeout
Generated illustration

What happened

  • The first symptom was a scheduled job that crashed every few days with a TypeError reading 'id' of undefined inside a loop over query rows.
  • The bug fires when a query errors after streaming at least one row, most often when statement_timeout (Postgres error 57014) cancels a scan already returning rows.
  • In the post's example, a query cancelled after 97 rows leaves the cursor at 97, so the next query's two rows land at indexes 97 and 98 in a 99-slot array.
  • The author checked postgres@3.4.9, the latest npm release, and the master branch, and found that neither resets the cursor after a failed query.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Where statement_timeout is set on the database role, as in the post, one service's slow scan arms the bug for an unrelated handler that next borrows that connection from the pool.
  • constraint A local test suite will not surface it, because a laptop rarely runs a query long enough to be cancelled; a repro has to force an error after the first row arrives.
  • decision Teams that apply the pnpm patch own a modified driver until upstream ships the reset, and have to check the patch against each postgres-js upgrade they take.

According to the post's reading of src/connection.js, each query in postgres-js gets its own result array, but the index it writes at is a single counter held by the connection [3]. Every DataRow message from the server lands at `result[rows++]` [3]. That counter returns to zero in two handlers only, CommandComplete and PortalSuspended [4]. A query that fails reaches neither. ErrorResponse records the error and returns, then ReadyForQuery nulls the query state and allocates `new Result()` without resetting `rows` [5].

The extra length is empty slots. The next query writes into a fresh array [5]. None of the cancelled query's rows carry over, and the new query's own rows sit intact after the holes [2]. On the job in the post, the run that reported 813 for a LIMIT 500 query had at least 313 empty slots ahead of its data, and the 534 run had at least 34 [2][1].

Nothing about a sparse array throws [7]. JSON.stringify writes the holes as nulls, for..of hands back undefined, and .map carries the holes through an ORM's mapping layer [7]. Code that calls .filter(Boolean) never sees the problem [7]. The worst case in the post is a destructured lookup: `const [row] = ...` followed by `if (!row) return notFound()` [8]. The record exists, `row` is undefined, and the code takes the not-found branch. The team had a version of that on a write path [8]. "No error, no log, just a wrong decision," the author wrote [9].

The post gives a log signature for confirming it. A logged row count exceeds the query's LIMIT. A TypeError on undefined inside a loop over results appears within a few seconds of `canceling statement due to statement timeout` in the same process [12]. The process boundary matters because the pool lives in the process; a timeout in a different pod belongs to a different pool [12].

The patch adds `rows = 0` directly after `result = new Result()` in ReadyForQuery [15]. That puts the reset where the driver already starts the next query's array. It is a small, well-placed fix, and the author's diagnosis traces it to the exact line. Until PR #1120 lands [14], the author applies it with `pnpm patch postgres`, and `pnpm patch-commit` writes the patch file and a patchedDependencies entry into the workspace [15].

I'd carry that patch before I'd guard call sites. A call-site guard has to be written at every query that indexes, counts, or destructures results. The cheapest guard, .filter(Boolean), is the same call that kept the bug invisible for anyone who used it [7]. The patch is one line in one file [15].

What to watch

  • A postgres-js release after 3.4.9 that includes PR #1120; teams carrying the pnpm patch can drop it at that point.
  • Maintainer activity on issue #1181, where users are still adding comments about the stale cursor.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories