A practical playbook for hunting slow queries in a Node.js + PostgreSQL production app — how I diagnose, index and rewrite queries with EXPLAIN ANALYZE, and how to avoid the traps that turn a small fix into a scary migration.
- Published
- Reading
- 3 min
- Author
- Yaroslav Tsarenko
If you own a Node.js backend on top of PostgreSQL long enough, you will meet a query that once took 40 ms and now takes 3 seconds. Nothing "changed" — the data grew, the plan flipped, and the endpoint quietly became your worst SLO offender.
This is the playbook I use in production to bring queries back under 100 ms without shipping a scary migration.
1. Measure the right thing first
Do not tune from vibes. Turn on pg_stat_statements and rank by total_time:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;Then take the worst offender and run it with EXPLAIN (ANALYZE, BUFFERS). Buffers matters — a plan that reads 1.4M pages from disk is a very different bug from one that reads 40 pages.
2. Read the plan like a story
Two patterns cover most incidents:
- Sequential Scan on a large table where you expected an index. Usually a missing index, a query that hides the column behind a function, or a mismatched type (
textvscitext,timestampvstimestamptz). - Nested Loop with a huge outer side. Frequently a bad row-count estimate — statistics are stale, or the planner has no idea about your correlated predicates.
3. Indexes: the boring wins
Order in a compound index matters. If you filter by tenant_id and sort by created_at DESC, this index kills the sort:
CREATE INDEX CONCURRENTLY idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);CONCURRENTLY is not optional in production — it avoids exclusive locks on writes. Pair every new index with a check on write amplification (pg_stat_user_indexes shows unused indexes you can drop).
Partial indexes are the cheat code:
CREATE INDEX CONCURRENTLY idx_orders_open
ON orders (tenant_id, created_at DESC)
WHERE status = 'open';If most of your reads hit "open" orders, a partial index is smaller, hotter in cache, and lets the planner pick it aggressively.
4. Kill N+1 at the source
The single most common Node.js performance bug is not the query — it is 200 of them, one per row. Two rules:
- If you are calling
db.queryinside afor...ofon results, you are doing N+1. Batch it. - If your ORM lets you
includeorselecta relation in one round-trip, do that; else useWHERE id = ANY($1::int[])with an array of IDs collected on the Node side.
5. Pagination that survives 10M rows
Offset pagination breaks quietly:
SELECT * FROM orders ORDER BY created_at DESC OFFSET 200000 LIMIT 20;At page 10,000 the database still has to scan and discard 200,000 rows. Keyset pagination fixes it:
SELECT * FROM orders
WHERE created_at < $1
ORDER BY created_at DESC
LIMIT 20;The cursor is the last created_at (plus id as a tiebreaker). Constant time, no matter how deep the user scrolls.
6. Connection pooling in a serverless world
If you run Node.js on Vercel or Lambda, each invocation can open a fresh connection. PostgreSQL burns down. Fix it once:
- Put PgBouncer (or Supavisor) in front of your database in transaction pooling mode.
- Set
pool.maxon the Node side low (2–5). Serverless multiplies connections without you noticing. - Prefer stateless SQL — no session-level
SET, no prepared statements that assume a stable session.
7. Migrations that do not scare you
Two rules that saved me multiple midnight rollbacks:
- Never add a
NOT NULLcolumn with a default on a big table without a two-step migration: add nullable → backfill in batches → setNOT NULL. - Never drop a column in the same deploy that stops writing to it. Ship a release that stops using it, wait a full day, then drop.
8. Tools I keep loaded
pg_stat_statementsfor triage.pgBadgeron the log for slow-query storms.EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)piped into explain.dalibo.com for a visual plan.- A single
docs/db-runbook.mdin the repo listing every "we tried this and it did not work" incident. Future you will thank present you.
Closing thought
Query optimization is not a talent. It is a loop: measure → read the plan → change one thing → measure again. Do it three times and you will have a "3s to 50ms" story of your own.
If your Node.js + PostgreSQL app has an endpoint that used to be fast and is not anymore — get in touch. I do this exact work for teams that need it done without drama.