Neon Postgres Performance: What Slows Serverless Postgres Down
The first query of the morning takes two seconds. Every query after it takes twenty milliseconds. Your monitoring fires an alert at 8:58 a.m., someone spends half an hour chasing a regression that doesn't exist, and by lunch the database is "fine again."
That morning ritual is the signature of running Neon Postgres without accounting for the serverless part. Neon's compute scales to zero when idle and wakes on the first connection: a deliberate trade of one slow request for a bill that stops running overnight. It's often a good trade. But it's only one of several places where Neon's architecture changes what "slow" means, and the classic Postgres failure modes still apply on top.
This guide walks through the five performance problems that matter most on Neon: what each one is, why it happens, one way to detect it today, and what it typically costs you in latency or compute-hours. Then it walks the 8:58 alert end to end, so you can see how the five separate in practice.
Part two is a hands-on Neon tuning guide using only native tools, and part three covers automating the whole loop with an AI agent.
01Serverless
Cold starts
Scale-to-zero trades a slow first query for a bill that stops overnight.
02Serverless
Autoscaling limits
A ceiling too low throttles peaks; a floor too high bills idle hours.
03Serverless
Pooled vs direct
Serverless functions on the direct endpoint exhaust connections in one spike.
04Classic Postgres
Missing indexes
Sequential scans that also evict the local file cache for everyone else.
05Billing
CU-hours
Slow queries keep compute large and awake, so waste becomes the price.
1. Scale-to-zero cold starts
What it is. By default, a Neon compute suspends after 5 minutes of inactivity and restarts when the next connection arrives. That restart, the cold start, typically adds a few hundred milliseconds to a couple of seconds to the first query. The restarted compute also comes up with a cold local file cache, so the next several queries read from storage instead of memory (Neon docs: scale to zero).
01Idle
No traffic
The last connection closes after the evening batch.
02Five minutes
Suspend
The compute scales to zero. Billing for it stops.
03First request
Cold start
The compute restarts and the first query waits for it.
04Next queries
Cold cache
Pages come from storage until the local file cache warms up.
Why it happens. It isn't a bug. It's the pricing model. Scale-to-zero is why a staging database costs almost nothing on weekends. The problem is applying the default to the wrong workload: a production API with steady traffic gaps, a cron job that connects every 10 minutes, a health check that keeps waking the compute for nothing.
How to detect it. Let a branch sit idle past its suspend timeout, then time the first connection:
time psql "$DATABASE_URL" -c "SELECT 1;"
Run it again immediately. The difference between the two is your cold-start tax. In the Neon Console, check Branches → your branch → Compute for the scale-to-zero setting, and the Monitoring page to see how often the compute actually suspends and resumes.
Typical impact. One to three seconds on the first request after idle. Irrelevant for dev branches; a real p99 problem for production APIs with bursty traffic. Disabling scale-to-zero on production (a paid-plan setting) trades that latency for roughly the cost of running your minimum compute size continuously.
2. Autoscaling limits set wrong, in either direction
What it is. Neon autoscaling moves your compute between a minimum and maximum size, measured in Compute Units (1 CU is 1 vCPU and 4 GB of RAM), based on load (Neon docs: autoscaling). Two opposite misconfigurations produce two opposite bills. A ceiling set too low means your compute pins at max CU during peak load, queries queue, and latency climbs while the graph shows a flat line at the limit. A floor set too high means you're billing for 4 CU at 3 a.m. to serve zero requests.
Why it happens. The limits get set once, at project creation, usually at whatever the default was. Nobody revisits them because nothing "breaks": a too-low ceiling looks like a slow app, and a too-high floor looks like a normal bill.
How to detect it. Open Monitoring in the Neon Console and read the CPU and RAM charts against your autoscaling range. Compute flatlined at the maximum during business hours means the ceiling is throttling you. Compute never leaving the minimum means the floor (and your bill) may be oversized. Cross-check with what Postgres itself sees:
CREATE EXTENSION IF NOT EXISTS neon_utils;
SELECT num_cpus();
Typical impact. A too-low ceiling commonly shows up as 2 to 5x query latency during peaks with no code change to blame. An oversized floor is pure spend, often 30 to 60% of the compute bill on projects that set a "safe" minimum and forgot it.
3. Connection management: pooled vs. direct endpoints
What it is. Neon gives every branch two connection strings: a direct one, and a pooled one (hostname contains -pooler) that routes through PgBouncer in transaction mode and supports up to 10,000 concurrent client connections (Neon docs: connection pooling). The direct endpoint's max_connections scales with compute size and is small on small computes. Serverless platforms such as Vercel functions and Lambda open connections per invocation, and pointed at the direct endpoint they exhaust it in one traffic spike: FATAL: sorry, too many clients already.
Why it happens. The direct string is what people copy first, and it works fine in development. The failure only appears under production concurrency. The reverse mistake exists too: session-level features (advisory locks, LISTEN/NOTIFY, prepared statements outside protocol support) misbehave through a transaction-mode pooler, so some workloads genuinely need the direct endpoint.
How to detect it. Check what you're connected through (look for -pooler in your DATABASE_URL host), then compare live connections to the limit:
SELECT count(*) AS connections,
current_setting('max_connections') AS max_connections
FROM pg_stat_activity;
Connections regularly above about 70% of max_connections on a direct endpoint is a countdown, not a steady state.
Typical impact. Connection exhaustion is binary: everything works until the spike, then every new request errors for minutes. Moving the right workloads to the pooled endpoint is usually a one-line env-var change.
4. The classic Postgres suspects: missing indexes and seq scans
What it is. Neon is real Postgres, so the oldest performance problems in the book apply unchanged: a query filtering on an unindexed column walks the whole table, and does it again every time it runs. On Neon there's a twist that makes it worse. Large sequential scans evict everything else from the compute's local file cache, so one bad report query degrades every query behind it.
Why it happens. The table was small when the query was written. It isn't anymore.
How to detect it. pg_stat_statements works on Neon like anywhere else (Neon docs: pg_stat_statements):
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 10;
Then find the tables being scanned instead of indexed:
SELECT relname, seq_scan, idx_scan, seq_tup_read
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY seq_tup_read DESC
LIMIT 10;
Typical impact. The most common finding on any Postgres, Neon included. One missing index on a hot query path routinely accounts for 10 to 100x that query's latency and, on Neon, for a large share of the CPU that autoscaling is billing you to provide. Neon also gives you an unusually safe way to fix it: branch production data, create the index on the branch, measure, then apply to main. Part two covers that branching workflow in detail.
5. Compute-hours pricing: slow queries are line items
What it is. Neon bills compute in CU-hours: compute size multiplied by time running (Neon pricing). That collapses the old wall between performance work and cost work. A query that burns CPU for 400 ms instead of 8 ms doesn't only annoy a user. It pushes autoscaling to a bigger compute and keeps an otherwise idle compute awake past its suspend timeout. On provisioned Postgres, waste hides inside a fixed instance price. On Neon, waste is the price.
Why it happens. Teams port their RDS mental model ("the instance costs what it costs") and never connect the billing graph to pg_stat_statements.
How to detect it. In the Neon Console, open Billing → Usage and look at compute-hours per project over the month, then put it side by side with your top queries by total_exec_time from the query above. If one query dominates total execution time, it also dominates your compute bill.
Typical impact. Variable, but the direction is constant: on autoscaling workloads, cutting total query execution time cuts the bill roughly proportionally. Teams that fix their top three queries commonly shave 20 to 40% off compute-hours without touching a single setting.
The 8:58 alert, walked end to end
Back to the opening scene. The p99 alert fires at 8:58 on a customer-facing endpoint, and by 9:30 everything looks normal. The numbers in this walkthrough are illustrative. Three readings fit:
- A cold start. Predicts one or two very slow requests right after a suspend, then normal latency, and a compute start time just before the alert.
- An autoscaling ceiling. Predicts CPU flat at max CU during the slow window, with many slow requests rather than a few.
- A real query regression. Predicts a query in
pg_stat_statementswhose mean time stays high after the morning warm-up.
The cheapest check goes first. When did this compute last start?
SELECT pg_postmaster_start_time();
It returns 08:57:41. The compute was asleep all night and woke nineteen seconds before the alert. The Monitoring page agrees: zero compute overnight, then a resume at 08:57, and CPU nowhere near the autoscaling max. The ceiling reading is out.
The regression reading needs one more look, because the cold start explains the first request but not necessarily the next twenty minutes. pg_stat_statements only covers the time since 08:57 (its counters live in compute memory, so a suspend resets them), and that's enough here. The endpoint's query shows a mean of 18 ms across a few thousand calls, with shared_blks_read high only in the first minutes as the file cache warmed. No regression.
So the finding is a configuration choice, not a bug: a customer-facing production endpoint was inheriting the dev-friendly 5-minute suspend. The fix is a setting (disable scale-to-zero for that compute, or raise its timeout on plans that allow it), and the trade is explicit: a few dollars of always-on minimum compute against a daily latency spike for the first customer of the morning.
Why one-time tuning doesn't stick
| One-time tuning pass | What Neon actually needs | |
|---|---|---|
| Query stats | Read once, during the sprint | Captured before each suspend wipes them |
| Autoscaling range | Set at project creation | Re-checked when traffic shifts |
| Connection strings | Fixed for today's services | Checked for every new function or service |
| Index fixes | Tested in staging that approximates prod | Tested on a branch of real data |
| Cost | Reviewed when the invoice surprises someone | Read next to query time, weekly |
Every one of these five problems regenerates. Tables grow past their indexes. A new deploy adds a query nobody profiled. Traffic grows past the autoscaling ceiling that was fine in March. A new serverless function ships with the direct connection string because that's what was in the README. The tuning sprint you ran last quarter was accurate, last quarter.
The deeper difference on Neon is that the evidence itself is short-lived. Stats reset on every suspend, so a process that depends on someone remembering to look will often find nothing to look at.
The teams that stay fast on Neon treat this as a loop, not a project: watch the Monitoring dashboard, read pg_stat_statements weekly, re-check autoscaling limits when traffic shifts, and use branches to test every fix against real data before it touches main. Part two of this series is the full DIY version of that loop with nothing but Neon's native tools and plain SQL.
Or run the loop continuously
Everything above can be watched by software instead of a calendar reminder. CloudThinker connects to Neon read-only and continuously monitors slow queries, autoscaling headroom, connection saturation and index health. It validates proposed fixes on a Neon branch before anything touches main, and it acts within Auto Mode, so you decide what runs on its own and what waits for your approval. Part three of this series shows how that works.
Try CloudThinker free with 100 premium credits and no card required.
