Skip to content

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:

sql
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 (text vs citext, timestamp vs timestamptz).
  • 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:

sql
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:

sql
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.query inside a for...of on results, you are doing N+1. Batch it.
  • If your ORM lets you include or select a relation in one round-trip, do that; else use WHERE id = ANY($1::int[]) with an array of IDs collected on the Node side.

5. Pagination that survives 10M rows

Offset pagination breaks quietly:

sql
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:

sql
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.max on 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 NULL column with a default on a big table without a two-step migration: add nullable → backfill in batches → set NOT 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_statements for triage.
  • pgBadger on 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.md in 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.