10 min read
Why Is My Supabase / Neon Postgres Slow? The Complete Diagnosis Guide
TL;DR: Slow Postgres on Supabase, Neon, or RDS is almost never a hardware problem. In order of how often they're the cause, the culprits are: (1) a missing index on a frequently filtered column, (2) an unindexed foreign key (Postgres auto-indexes primary keys but not foreign keys), (3) untuned config — most often shared_buffers left at the 128MB default and random_page_cost left at 4 on SSD storage, (4) dead-tuple bloat from autovacuum falling behind, and (5) N+1 query patterns that are individually fast but collectively crushing. You can diagnose every one of these with read-only queries against the system catalogs and pg_stat_statements, and most fixes are a single line of SQL. This guide walks through each, in order, with the exact commands.
If you'd rather have this done automatically: run a free read-only scan, paste the JSON, and get a graded report with the fixes attached. But you don't need it to follow along.
First, a mental model: slow vs. expensive
Before you change anything, internalize one distinction, because it determines which fix you reach for.
A slow query takes a long time per execution — a 2-second report query, a join over an unindexed table. You fix these with indexes or query rewrites.
An expensive query might be fast per execution but is run so often that it dominates your database's total CPU. The textbook case is a 3-millisecond query called two million times a day — that's 95 minutes of pure DB CPU hiding inside a query that looks perfect in isolation. You fix these by changing how the application calls the database (usually batching an N+1).
You cannot tell these apart by feel. You need pg_stat_statements, which we'll set up in a moment.
Quotable: A slow query and an expensive query are different problems. The first needs an index; the second needs a code change.
pg_stat_statementsis the only thing that tells them apart.
Step 0: Enable pg_stat_statements
This is the single highest-leverage extension in Postgres for performance work. It records every query shape (with literals stripped) alongside its call count, mean execution time, and total execution time.
Check whether it's on:
SELECT * FROM pg_stat_statements LIMIT 1;
If that errors, enable it. On Supabase and Neon the library is preloaded, so you usually just need:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
On RDS / Aurora, add pg_stat_statements to shared_preload_libraries in the parameter group, reboot the instance, then CREATE EXTENSION. On a self-managed Postgres, set it in postgresql.conf and restart:
shared_preload_libraries = 'pg_stat_statements'
Everything below assumes it's available. If it isn't, you can still do the index and bloat work — you just won't see your query-level cost centers.
Cause #1: A missing index on a sequentially scanned table
This is the most common cause of "it was fast, now it's slow." The table grew past the point where Postgres can afford to scan the whole thing, but there's no index to let it jump straight to the rows you want.
How to find it
SELECT relname,
seq_scan,
idx_scan,
n_live_tup
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_scan DESC
LIMIT 20;
Read it like this: a table with a high seq_scan, low idx_scan, and large n_live_tup is being repeatedly read end-to-end. That's a missing index. A small lookup table with high seq_scan is fine — scanning 50 rows is cheap, and an index would be ignored anyway.
To confirm on a specific query, run EXPLAIN ANALYZE and look for a Seq Scan node where you expected an Index Scan:
EXPLAIN ANALYZE
SELECT * FROM order_items WHERE status = 'pending';
If you see Seq Scan on order_items (rows=1400000) for a query that should return a handful of rows, that's your culprit.
How to fix it
CREATE INDEX CONCURRENTLY idx_order_items_status
ON order_items (status);
Always use CONCURRENTLY on a live database. A plain CREATE INDEX takes an ACCESS EXCLUSIVE-style lock that blocks writes for the entire build; CONCURRENTLY builds the index without blocking your application. It's slower and can't run inside a transaction block, but it won't take your app down.
Quotable: On a production database, always
CREATE INDEX CONCURRENTLY. A plainCREATE INDEXblocks writes for the entire build.
Cause #2: An unindexed foreign key
This deserves its own section because it surprises almost everyone.
Postgres automatically creates an index for primary keys and unique constraints. It does NOT automatically create one for foreign keys.
So if order_items.order_id references orders(id) and you never explicitly indexed order_items.order_id, two bad things happen:
- Every join or filter on
order_idis a sequential scan. - Every delete or update of a parent row forces Postgres to scan every child table to enforce the foreign key constraint — without an index, that's a full scan, frequently while holding locks. On a busy table, parent deletes turn into lock storms.
How to find every unindexed foreign key
SELECT c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1
FROM pg_index i
WHERE i.indrelid = c.conrelid
AND a.attnum = ANY(i.indkey)
);
How to fix it
One index per offending column:
CREATE INDEX CONCURRENTLY idx_order_items_order_id
ON order_items (order_id);
In real applications, indexing a single hot foreign key is routinely worth a 100x improvement on the affected queries — we've seen a join drop from ~340ms to under 3ms from this one change.
Quotable: Postgres auto-indexes primary keys but not foreign keys. An unindexed foreign key is the most common single cause of a slow Supabase or Neon app.
Cause #3: Config left at Postgres's tiny defaults
Postgres ships with conservative defaults so it can boot on almost any machine. On a real instance, several of them are actively hurting you.
shared_buffers stuck at 128MB
shared_buffers is Postgres's dedicated page cache. The default is 128MB — sized so Postgres can start on a Raspberry Pi. On an instance with several gigabytes of RAM, 128MB means your hot tables and indexes don't fit in cache, so Postgres re-reads them from disk constantly.
Check it:
SHOW shared_buffers;
If it says 128MB and you have real RAM, raise it to roughly 25% of total RAM:
ALTER SYSTEM SET shared_buffers = '1GB'; -- then restart
(shared_buffers requires a restart to take effect. On Supabase/Neon/RDS this is managed through their dashboard or parameter groups rather than ALTER SYSTEM directly, but the target is the same.)
random_page_cost still at 4 on SSD
random_page_cost tells the planner how expensive a random disk read is relative to a sequential one. The default of 4 assumes a spinning disk. Every modern cloud provider — Supabase, Neon, RDS — runs on SSD or NVMe, where random reads are nearly as cheap as sequential ones.
When random_page_cost is too high, the planner under-values your indexes and chooses sequential scans even when an index scan would be faster. You can have the right index and still get a seq scan because the planner doesn't believe the index is worth it.
ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf(); -- no restart needed for this one
work_mem too low → temp-file spills
work_mem is how much memory a single sort or hash operation may use before it spills to a temporary file on disk. The default is 4MB. When a sort doesn't fit, you get a disk spill, which is brutally slow.
You'll see it in EXPLAIN (ANALYZE, BUFFERS) as:
Sort Method: external merge Disk: 24096kB
If you see "external merge Disk", that operation spilled. Raise work_mem until it stays in memory:
ALTER SYSTEM SET work_mem = '32MB';
SELECT pg_reload_conf();
Caution: work_mem is per operation, not per connection. A single complex query can use it several times over, and it multiplies across concurrent connections. Don't set it to gigabytes — raise it deliberately, watch for spills, and consider setting it per-session for known-heavy reporting queries instead of globally.
Quotable: The three config defaults that quietly cripple Postgres performance:
shared_buffers=128MB,random_page_cost=4on SSD, and awork_memtoo small to keep sorts off disk.
Cause #4: Dead-tuple bloat
Postgres uses MVCC (Multi-Version Concurrency Control). When you UPDATE a row, Postgres doesn't overwrite it — it writes a new version and marks the old one dead. A DELETE just marks the row dead. Those dead tuples remain until VACUUM removes them, and autovacuum runs in the background to do that.
On write-heavy tables, dead tuples can accumulate faster than autovacuum clears them. Then every scan wades through dead rows, the table balloons on disk (we've seen tables 3x larger than their live data), and your cache fills with garbage. Adding RAM doesn't help — it just caches the bloat faster.
How to find it
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / nullif(n_live_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
ORDER BY dead_ratio DESC NULLS LAST
LIMIT 10;
A dead_ratio above 0.2 on a large table is worth acting on.
How to fix it
Reclaim space and refresh planner statistics now:
VACUUM ANALYZE my_bloated_table;
Then prevent recurrence by making autovacuum more aggressive on that specific table (don't lower it globally — that wastes resources on calm tables):
ALTER TABLE my_bloated_table
SET (autovacuum_vacuum_scale_factor = 0.02);
(VACUUM FULL reclaims disk fully but takes an exclusive lock and rewrites the table — reserve it for maintenance windows. Routine VACUUM is non-blocking and usually enough.)
Quotable: "We added RAM and it's still slow" usually means dead-tuple bloat. More memory just caches the dead rows faster. The fix is VACUUM, not hardware.
Cause #5: Unused and duplicate indexes
Indexes aren't free. Every index must be updated on every INSERT, UPDATE, and DELETE that touches its columns, and it consumes disk and cache. Teams add indexes "just in case" and never remove them, accumulating an index graveyard that taxes every write for zero read benefit.
Find indexes that have never been scanned
SELECT indexrelname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
idx_scan = 0 means the index has never been used since stats were last reset. Before dropping, confirm it isn't backing a unique or foreign-key constraint and that your stats window is long enough to be representative.
Drop them safely
DROP INDEX CONCURRENTLY unused_index_name;
CONCURRENTLY here too, so the drop doesn't block reads. Duplicate indexes — two indexes covering the same column set — are the same waste and harder to spot by eye; a tool will catch them faster than you will.
Cause #6: N+1 query patterns
Back to the slow-vs-expensive distinction. Pull your top cost centers:
SELECT query,
calls,
mean_exec_time,
total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
If the top entry has a tiny mean_exec_time but an enormous calls count, that's an N+1: the application fetched a list, then looped to fetch a related row for each item one query at a time. The fix is in the app, not the database — batch the lookups:
SELECT * FROM avatars WHERE user_id = ANY($1);
Collapsing two million tiny queries into a few hundred batched ones routinely drops database CPU from the high-70s to the low-20s overnight.
The complete checklist
Run these in order. Items 1, 2, and 3 fix the majority of "Supabase/Neon is slow" cases on their own.
- ☐ Enable
pg_stat_statementsif it isn't on. - ☐ Find sequentially scanned large tables → add the missing index (
CONCURRENTLY). - ☐ Find unindexed foreign keys → index every one (
CONCURRENTLY). - ☐ Check
shared_buffers— if it's128MB, raise to ~25% of RAM. - ☐ Check
random_page_cost— if it's4on SSD, set to1.1. - ☐ Check for temp-file spills → raise
work_mem(carefully). - ☐ Measure
dead_ratio→VACUUM ANALYZEand tighten autovacuum on bloated tables. - ☐ Find unused/duplicate indexes →
DROP INDEX CONCURRENTLY. - ☐ Sort
pg_stat_statementsbytotal_exec_time→ fix N+1 patterns in the app. - ☐ Re-measure and confirm the win.
A worked example
A real Supabase app: the orders page took 4 seconds. The cause was two of the items above stacked.
order_items.order_idwas an unindexed foreign key → a seq scan over 1.4M rows on every load. Indexing it dropped that path from ~340ms to under 3ms.shared_bufferswas at the 128MB default andrandom_page_costwas at 4 → the working set kept falling out of cache and the planner avoided indexes. Raisingshared_buffersand settingrandom_page_cost = 1.1fixed both.
Final result: 4,000ms → 180ms. No new hardware. No rewrite. Three lines of SQL. That's typical, not lucky.
You don't have to do this by hand
Every diagnostic above is a read-only query against the system catalogs and pg_stat_statements. The hard part isn't running them — it's knowing the right thresholds (a dead_ratio of 0.2 matters on a 50M-row table and is noise on a 200-row one) and knowing which finding to fix first.
That's exactly what pg-checkup automates. You run a single read-only collector script, paste the JSON, and get a letter grade (A+ to F) plus a ranked list of findings — each with copy-paste remediation SQL and the expected win. It never asks for your connection string and never stores your data.
If your Postgres feels slow, run a free scan and find out what grade it gets. Worst case, you walk away with the checklist above already run for you.
Don't want to run it by hand?
Get a graded report with every fix attached — free, no signup.
Grade my Postgres