Skip to content
PostgreSQL

Scaling PostgreSQL for Go Applications: Read Replicas, Connection Pools and Query Performance

A practical order of operations for scaling PostgreSQL under a growing Go service: query optimization first, then pooling, then read replicas — and why the order matters.

GoBackend.dev13 min read
GoPostgreSQLScalingPerformanceDatabase

The problem

A service that used to feel instant starts feeling slow as traffic grows, and the reflex response is almost always the same: add a read replica, add a bigger instance, add more connections. Sometimes one of those is genuinely the right call. Often it isn't — it's an expensive way to make a poorly-optimized query run on more hardware instead of fixing the query, and the slowness comes right back once traffic grows again.

Why it matters

Scaling PostgreSQL has a real order of operations, and skipping ahead in it wastes money and defers the actual fix:

  1. Query optimization and indexing — free, and fixes the root cause if the root cause is a bad query plan. See EXPLAIN ANALYZE in PostgreSQL for finding these.
  2. Connection pooling — cheap, and fixes the problem if it's connection overhead or exhaustion, not query cost. See PostgreSQL Connection Pooling in Go.
  3. Vertical scaling (a bigger primary) — buys headroom without architectural change, at a real dollar cost that grows faster than linearly at the high end.
  4. Read replicas — genuine horizontal scaling for read-heavy workloads, but introduces replication lag and consistency trade-offs that don't exist on a single instance.
  5. Partitioning — for tables that have grown large enough that even well-indexed queries and vacuum are struggling with sheer row count.

Doing step 4 before step 1 is the single most common mistake: a read replica makes a badly-indexed query run exactly as slowly, just on different hardware, while adding a new failure mode (stale reads) that wasn't there before.

Why blindly adding replicas doesn't fix bad queries

A sequential scan against a 10-million-row table because of a missing index takes the same number of page reads whether it runs on the primary or a replica with identical hardware. Read replicas add capacity — more machines able to serve read queries in parallel — they don't make any individual query faster. If the actual problem is one endpoint issuing an unindexed scan on every request, spreading that same expensive scan across three replicas instead of one just means three machines are now doing expensive work instead of one, at three times the infrastructure cost, with the same per-request latency the user actually experiences.

Read replica architecture

Rendering diagram…

The router in application code is a deliberate simplification of a real decision: which queries are safe to send to a replica, and which must go to the primary. A practical starting split in Go — two separate connection pools, chosen explicitly per query rather than automatically:

type DB struct {
	Primary *pgxpool.Pool // all writes, and reads that must be fresh
	Replica *pgxpool.Pool // reads that can tolerate a small lag
}
 
func (r *OrderRepository) GetByID(ctx context.Context, id string) (Order, error) {
	// Fine on a replica: a customer viewing their own order a moment
	// after creating it might occasionally see a brief "not found" if
	// they refresh immediately — an acceptable trade-off for this read.
	return scanOrder(r.db.Replica.QueryRow(ctx, getOrderQuery, id))
}
 
func (r *OrderRepository) Create(ctx context.Context, o Order) (Order, error) {
	// Writes always go to the primary — replicas are read-only by
	// construction in PostgreSQL streaming replication.
	return scanOrder(r.db.Primary.QueryRow(ctx, insertOrderQuery, o.CustomerID, o.TotalCents))
}

Consistency implications: replication lag

A read replica is asynchronously behind the primary by some amount of time — usually milliseconds, but under load or during a large write burst, it can grow to seconds. A read immediately after a write, if routed to a replica, can return stale or missing data. This is the fundamental trade-off of read replicas: more read capacity, in exchange for "read your own write" no longer being guaranteed unless you specifically route that read to the primary.

The practical pattern: route reads that must reflect the caller's own recent write to the primary (a user viewing the order they just placed, inside the same request flow); route reads that tolerate slight staleness — dashboards, listing pages, anything aggregate or historical — to a replica. Getting this split wrong in the "too aggressive" direction shows up as confusing, intermittent "my data disappeared" reports that are actually just replication lag.

Caching as a complementary layer

Read replicas and caching (see Caching in Go: Redis, Cache-Aside and Cache Invalidation) solve overlapping but distinct problems: a cache avoids hitting the database at all for hot, repeatedly-read data; a replica adds database capacity for reads that do need to hit a real query. A well-cached hot path often needs no replica at all, because the replica's added capacity is only useful for the requests that actually reach the database — cache what you can before reaching for more database hardware to serve the same repeated queries.

Partitioning

Once a table's row count and index size grow large enough that routine maintenance (VACUUM, index rebuilds) and even well-planned queries start taking meaningfully longer, partitioning — splitting one logical table into multiple physical ones, commonly by date range — turns operations like "delete everything older than 90 days" from a slow row-by-row DELETE into a near-instant DROP of an old partition, and lets the query planner skip partitions it knows can't contain relevant rows entirely. This is a structural change worth making once volume actually justifies it, not a default for every table from day one.

Practical metrics to monitor

  • Replication lag (pg_stat_replication.replay_lag on the primary) — the direct signal for how stale a replica's reads currently are.
  • Cache hit ratio (pg_statio_user_tables) — a low hit ratio means queries are reading from disk more than from PostgreSQL's own buffer cache, often solvable by more memory or better indexing rather than more compute.
  • Connection counts vs max_connections — the ceiling from PostgreSQL Connection Pooling in Go, now multiplied across a primary and every replica.
  • Slow query log / pg_stat_statements — the query-level signal that tells you whether step 1 (optimization) still has unclaimed wins before reaching for more hardware.

Production considerations

Load test before and after any scaling changeLoad Testing a Go API is exactly the tool for confirming a change actually moved the metric you expected (p95/p99 latency, not just throughput) rather than assuming it did because it sounds like it should.

A read replica is also a disaster-recovery asset, not just a performance one — but conflating the two roles (using your only replica both for failover and for absorbing significant read load) means a replica promotion during an incident starts from a machine that's already under production load, which is a worse position to fail over from.

Common mistakes

  • Adding a replica before checking for a missing index — the fix that costs nothing gets skipped in favor of the fix that costs a new server.
  • Routing "read your own write" queries to a replica, producing intermittent, hard-to-reproduce staleness bugs.
  • Treating connection pool sizing as solved once rather than re-checking it every time replica count or replica count changes the total connections hitting the primary.

Summary

Scaling PostgreSQL under a growing Go service has an order that matters: fix what indexing and query optimization can fix first, since it's free and addresses the root cause when the root cause is a bad plan; size connection pooling correctly next; only then reach for vertical scaling or read replicas, which add real capacity but also add replication lag and a consistency decision that has to be made explicitly per query. Skipping ahead to replicas before exhausting the cheaper fixes is the most common way this goes wrong — it adds cost and a new failure mode without touching the actual bottleneck.