Skip to main content

Command Palette

Search for a command to run...

Query Pipelining in node-postgres: 2-3x Throughput With One Line of Code

Updated
8 min readView as Markdown
Query Pipelining in node-postgres: 2-3x Throughput With One Line of Code

Each time you send a query to PostgreSQL, there’s a delay while your client waits for a response before sending the next one. This is fine for one query, but if you send ten, you wait ten times when you could have waited just once.

PostgreSQL has allowed pipelining since version 7.4, released in 2003. With pipelining, a client can send several queries in a row without waiting for each response, and the server handles them in order. Each query still gets its own result and error handling, so you don’t have to wait between them.

PostgreSQL 14, released in September 2021, added a pipeline API to libpq with functions like PQenterPipelineMode(), PQpipelineSync(), PQexitPipelineMode(), and PQpipelineStatus(). Craig Ringer and Alvaro Herrera developed this feature. The release notes say it lets applications send several queries at once, which reduces latency. You can find full details in the libpq pipeline mode documentation.

Pipelining has always been possible at the protocol level, even before libpq or PostgreSQL 14. Any client that talks directly to the wire protocol can send several queries before reading the responses. node-postgres does this by implementing the PostgreSQL wire protocol in JavaScript, so it can pipeline with any PostgreSQL version from 7.4 onward. The native client (pg-native), which uses libpq, requires PostgreSQL 14 or newer client libraries.

Until now, node-postgres didn’t use this feature.

Opt-in query pipelining shipped in pg 8.23.0 and pg-native 3.9.0, released on August 8, 2026 via PR #3652. It’s documented at node-postgres.com/features/pipelining.

npm install pg@^8.23.0

What it looks like

const { Client } = require('pg')

const client = new Client({ pipeline: true })
await client.connect()

const [users, orders, inventory] = await Promise.all([
 client.query('SELECT * FROM users WHERE id = $1', [userId]),
 client.query('SELECT * FROM orders WHERE user_id = $1', [userId]),
 client.query('SELECT * FROM inventory WHERE warehouse_id = $1', [warehouseId])
])

await client.end()

You only need to set one option, and there’s no new API to learn. All query types work, including plain text, parameterized, and named prepared statements. Queries are sent to the server right away, and responses come back in order.


How it works internally

Without pipelining, node-postgres keeps a queue of queries and handles them one by one. The client sends a query, waits for a ReadyForQuery message, and then sends the next.

When pipelining is turned on, the client manages queries using three queues:

  1. _queryQueue – queries waiting to be sent

  2. _sentQueryQueue – queries on the wire, waiting for responses

  3. _activeQuery – the query whose response is currently being processed

When you call client.query(), the query goes into _queryQueue. On the next event loop tick, _pulsePipelinedQueryQueue() drains the entire queue onto the wire, moving each query to _sentQueryQueue. As _ReadyForQuery messages arrive from the server, the next query is promoted from _sentQueryQueue to _activeQuery, and its response is delivered.

Each query sends a full Parse/Bind/Describe/Execute/Sync sequence. The Sync message is what gives each query its own error boundary in PostgreSQL’s protocol. A failing query in the middle of a batch does not affect the others:

const results = await Promise.allSettled([
 client.query('SELECT 1 AS num'),
 client.query('SELECT INVALID SYNTAX'),
 client.query('SELECT 3 AS num')
])

console.log(results[0].status) // 'fulfilled'
console.log(results[1].status) // 'rejected'
console.log(results[2].status) // 'fulfilled'

Benchmarks

All numbers are from a single client running against a local PostgreSQL instance. The pipelined benchmarks use batches of 10 concurrent queries dispatched with Promise.all.

JS client (pg)

Query type

Serial (qps)

Pipelined (qps)

Speedup

Simple SELECT 1

10,746

25,294

2.35x

Parameterized

9,516

14,226

1.50x

Named prepared

9,983

22,160

2.22x

Native client (pg-native)

Query type

Serial (qps)

Pipelined (qps)

Speedup

Simple SELECT 1

9,446

21,524

2.28x

Parameterized

8,670

20,792

2.40x

JS vs native comparison

Query type

JS pipelined (qps)

Native pipelined (qps)

Simple SELECT 1

25,294

21,524

Parameterized

14,226

20,792

The JS client is faster for simple queries because crossing from JavaScript to C++ for each query adds more overhead than native parsing saves. For parameterized queries, the native client is faster because libpq’s pipeline mode batches queries in C and avoids crossing the boundary for each one.

These results are from a local machine. On a real network with latency, pipelining gives an even bigger speed boost because it cuts down on idle time that grows with round-trip time. For example, if each round-trip takes 1ms, running 10 queries in serial means 10ms of waiting, but pipelining removes that delay.


Named prepared statements

Named prepared statements work with pipelining. When two pipelined queries use the same statement name, node-postgres tracks which Parse messages have been submitted (but not yet confirmed) and avoids sending duplicate Parse requests:

const queries = Array.from({ length: 100 }, (_, i) => ({
 name: 'get-user',
 text: 'SELECT * FROM users WHERE id = $1',
 values: [i]
}))

const results = await Promise.all(queries.map(q => client.query(q)))

The first query sends Parse, Bind, Execute, Sync. The remaining 99 skip Parse and send only Bind, Execute, Sync. This is tracked via a submittedNamedStatements map that prevents duplicate Parse messages even before the server confirms the first one.


Pool integration

Pass pipeline: true to the pool, and every client it creates has pipelining enabled:

const { Pool } = require('pg')

const pool = new Pool({ pipeline: true })
const client = await pool.connect()

const [r1, r2, r3] = await Promise.all([
 client.query('SELECT 1 AS num'),
 client.query('SELECT 2 AS num'),
 client.query('SELECT 3 AS num')
])

client.release()
await pool.end()

Note that pool.query() checks out a client for a single query and releases it immediately, so pipelining has no effect on that code path. Use pool.connect() to get a client you can pipeline on.


Native client support

pg-native 3.9.0 also supports pipelining, using the pipeline mode API that libpq introduced in PostgreSQL 14 (PQenterPipelineMode, PQpipelineSync, PQexitPipelineMode). This means you need PostgreSQL 14+ client libraries installed on the machine where your application runs. The native client collects all queued queries, sends them as a single pipeline batch through libpq, and delivers results back to each query’s callback.

The API is identical:

const { Client } = require('pg').native

const client = new Client({ pipeline: true })
await client.connect()

// Same Promise.all pattern works

PgBouncer compatibility

Pipelining works with PgBouncer in all pool modes: session, transaction, and statement. Since version 1.7 (December 2015), PgBouncer has tracked outstanding pipeline requests by counting Sync messages and only releases the server connection after all responses are delivered. node-postgres sends each query as a single Parse, Bind, Describe, Execute, and Sync sequence, so PgBouncer won’t reassign the connection in the middle of a query.

For named prepared statements through PgBouncer in transaction or statement mode, PgBouncer 1.21.0+ is required for automatic re-preparation support.


Graceful shutdown

If you call client.end() while pipelined queries are still running, it waits for all of them to finish before closing the connection. This works for both the JS and native clients:

const client = new Client({ pipeline: true })
await client.connect()

const p1 = client.query('SELECT 1')
const p2 = client.query('SELECT 2')
const endPromise = client.end()

// Both queries resolve normally
const [r1, r2] = await Promise.all([p1, p2])
await endPromise

💡 When to use it

Pipelining is best when you have several independent queries that don’t rely on each other’s results:

  • Fetching data from several tables for a page load

  • Inserting or updating multiple rows simultaneously

  • Running a batch of analytics or reporting queries

  • Preloading caches or materializing views

Don’t use pipelining if you need to see the result of one query before sending the next. With pipelining, all queries go out before any responses return, so you can’t make decisions based on earlier results. For dependent queries in a transaction, use sequential await calls.


Try it

It’s out. Upgrade and flip the flag:

npm install pg@^8.23.0
# optional, for the native client
npm install pg-native@^3.9.0

By default, pipelining is turned off. Upgrading does not change anything until you set pipeline: true on a Client or Pool. The feature works with the JS client, the native client, and the pool, and it comes with full integration tests and documentation. The pull request is brianc/node-postgres#3652.

If you try pipelining and run into any issues, open an issue on the node-postgres repository. Real-world edge cases are what help us find and fix any remaining problems.

As always, if you want to learn more about Platformatic, contact us!