Your database is fast. Then a traffic spike hits. Suddenly every dashboard load locks on a SELECT. CPU maxes out. Reads pile up. The bottleneck isn't your app code — it's one Postgres server trying to handle all read and write traffic at once. Spoiler: you don't need a shiny new database. You need read replicas. A read replica is a full copy of your primary database that accepts reads only. Writes still go to the primary, sync instantly to replicas. This decouples read capacity from write capacity. If 80% of your traffic is reads (true for most apps), you can scale that 80% without scaling the primary. Supabase Pro Plus includes read replicas. Here's the real playbook: when you actually need them, how to set them up, how to route traffic, and whether they're worth the cost.
Single Database: The Natural Limit
A single Postgres server is ridiculously capable. It handles millions of queries per day on modest hardware. Rebuild Relief, Velocity dashboards, and most SaaS apps run on one database for years. The primary is bottlenecked by I/O, CPU, or connection limits — not by "database design."
You hit a wall when:
- 1000+ concurrent reads per second: Your connection pool is exhausted. New queries queue.
- 50k+ daily active users: Even light read traffic (one dashboard load per user) adds up.
- CPU on the primary maxes at 80%+: Postgres is busy. Every slow query blocks reads.
- High-volume reporting queries: Analytical reads (dashboards, exports) on the same hardware as transactional reads.
- You can't optimize more: Indexes are in place, queries are tuned, but the server is just slow because it's busy.
If you're under 1k QPS and under 10k DAU, one database is fine. Most apps never need replicas. But if you're growing fast or running analytics on production, replicas start making sense around 20-50k DAU.
How Read Replicas Work
A read replica is a real, running Postgres instance that streams changes from the primary in near-real-time (typically 10-100ms lag). Your app reads from replicas, writes to the primary. Replicas are read-only: they reject INSERT, UPDATE, DELETE. The primary replicates all changes via WAL (write-ahead logging).
-- App sends writes to the primary
insert into events (user_id, event_name) values (123, 'dashboard_load');
-- App sends reads to a replica
select count(*) from events where user_id = 123;
-- The replica sees the insert ~50ms later via streaming replication
This works because reads don't need to be perfectly fresh. Most reads are OK with 50-200ms replication lag. Analytics, dashboards, and user-facing queries tolerate stale data. Only the app's transactional logic needs to write-to-read immediately.
Supabase Read Replicas: Setup and Routing
Supabase Pro Plus (and up) includes read replicas. You spin them up in the dashboard under "Database" → "Replication". Each replica is a separate PostgreSQL instance in a different region or availability zone. Supabase handles WAL streaming automatically.
Setup:
- Log into Supabase dashboard.
- Go to your project's "Database" settings.
- Click "Create read replica" — choose region.
- Supabase spins up a new instance, configures replication, assigns a connection string.
- Within 5-10 minutes, the replica is synced and ready.
Routing reads:
// Primary connection string (from Supabase)
const primary = createClient(
'https://xxx.supabase.co',
'anon-key'
);
// Replica connection string (from Supabase)
const replica = createClient(
'https://xxx-replica-1.supabase.co',
'anon-key'
);
// Use replica for reads (dashboards, analytics)
const userStats = await replica
.from('events')
.select('*')
.eq('user_id', 123);
// Use primary for writes (signups, transactions)
await primary
.from('events')
.insert({ user_id: 123, event_name: 'signup' });
For most apps, you want a simple heuristic: read-heavy queries (dashboards, reports) hit replicas; transactional writes always hit the primary. You can also load-balance across multiple replicas in a pool.
Edge case: fresh writes. If your app writes data and immediately reads it back on the same page load, the replica might not have the write yet (replication lag). Solution: read from the primary immediately after a write, or wait 100ms and retry on the replica. Real apps just read from the primary if they need fresh data. Replicas are best for "eventually consistent" reads.
Routing in Code: Load Balancer or Application Logic
Two patterns:
Pattern 1: Application-level routing (simple). Your app knows which connection strings are primary/replicas. You route reads vs writes in code.
// lib/db.ts
const primary = createSupabaseClient('primary-url');
const readReplicas = [
createSupabaseClient('replica-1-url'),
createSupabaseClient('replica-2-url'),
];
let replicaIndex = 0;
function getReadReplica() {
const replica = readReplicas[replicaIndex % readReplicas.length];
replicaIndex++;
return replica;
}
// Usage
export async function dashboardStats(userId) {
return getReadReplica()
.from('events')
.select('event_name, count(*) as count')
.eq('user_id', userId)
.group_by('event_name');
}
export async function recordEvent(userId, event) {
return primary
.from('events')
.insert({ user_id: userId, event_name: event });
}
This works. Simple to understand. Scales to 2-3 replicas. Beyond that, it gets messy.
Pattern 2: Load balancer (production). A proxy sits between your app and the database. You configure it to route writes to the primary, reads to replicas. Your app talks to one endpoint. The proxy handles routing.
// app doesn't care about replicas
const db = createSupabaseClient('load-balancer-url');
// Proxy rules (conceptual)
// - if query starts with SELECT → route to replica pool
// - if query is INSERT/UPDATE/DELETE → route to primary
// - replicate results back immediately
AWS RDS Proxy, PgBouncer, or a custom HAProxy layer handles this. Supabase doesn't ship a built-in proxy, but you can roll your own in a Netlify/Vercel edge function or use a third-party tool. For Rebuild Relief or small SaaS apps, application-level routing is fine.
Cost and Tradeoffs
Supabase Pro Plus is $25/month base. Each read replica adds ~$10-15/month (varies by compute size). So one primary + two replicas costs ~$50-55/month.
Is it worth it?
Compare to vertical scaling (bigger primary instance). A larger primary might be $50-100/month extra. Replicas let you scale reads independently, so you're paying only for read capacity, not write capacity. If your workload is 80% reads, 20% writes, replicas are cheaper than a bigger primary.
Real example: You have 50k DAU, 80% reading dashboards, 20% writing events. The primary is 70% CPU during peak. A 2x larger primary would cost 2x more (~$50 extra) but only solve the write bottleneck slightly. Two read replicas ($30 extra) move 80% of the load off the primary and are half the cost.
Tradeoff: operational complexity. You now manage two connection strings, monitor replica lag, handle fresh-read guarantees. Most teams aren't ready for this until they're at $10k+/month in database costs.
Monitoring Replication Lag
The replica streams changes from the primary, but it's never perfectly in sync. Lag is usually 10-100ms, but under load it can spike to 500ms or more. Monitor it.
-- On the replica, check how far behind it is
select now() - pg_last_wal_receive_lsn() as lag;
-- If lag is > 1 second, your replica is struggling
Supabase dashboard shows replica lag in the monitoring section. If it's consistently > 500ms, your primary is writing too fast or your replica's hardware is too small. Scale the replica up, or dial back write volume.
Six FAQs
Can I write to a replica by accident?
No. Replicas are read-only. Postgres rejects any INSERT/UPDATE/DELETE. If your code tries to write to a replica, the error bubbles up immediately: "permission denied." You'll catch it in testing.
What if the primary goes down?
Replicas keep working for reads, but your app can't write. You'll need a failover strategy: promote a replica to be the new primary. This is a manual step (Supabase doesn't auto-promote yet), but it's fast. Write to the new primary immediately, let your users read from the old replicas until you've migrated everything. Real high-availability setups use managed failover (like Render or AWS RDS), but most SaaS don't need it unless they're trading.
How much replication lag is acceptable?
Depends on your app. A dashboard showing "events from the last hour" is fine with 1-second lag. Real-time notifications need < 100ms. Most apps are OK with 100-500ms. If you need fresher data, read from the primary or wait a bit after writes.
Do I need replicas in multiple regions?
Only if your users are spread globally. A replica in a different region serves local users faster (lower latency). A replica in the same region as the primary is just for load balancing, not latency. Start with local replicas. Add geo replicas only if you have users on multiple continents.
Can I run analytics queries on replicas?
Yes, and you should. Analytical queries (full table scans, joins across big tables) are expensive. Run them on a replica to keep the primary free for transactional traffic. This is probably the biggest win for replicas: offload your slow reporting queries and watch the primary's CPU plummet.
What if my replica gets out of sync?
Very rare. Postgres replication is solid. But if it happens (network partition, hardware failure), Supabase will alert you. Rebuild the replica: Supabase has a "rebuild replica" button that re-syncs from the primary. Takes 5-30 minutes depending on database size.
The Bottom Line
Read replicas are for apps that have scaled past a single Postgres node on reads. If you're under 50k DAU and under 70% CPU, one database is fine. Once you hit that point, replicas are the next move: cheaper than vertical scaling, straightforward to set up on Supabase, and massive upside for read-heavy workloads (dashboards, analytics, reporting). The operational lift is small: route reads to replicas, monitor lag, done. For deeper dive into query optimization before you need replicas, see EXPLAIN ANALYZE — hunting slow Postgres queries. Ready to architect your data layer? Check Aidxn Design pricing for database performance consulting.