Skip to content

Scale + Connection Limits — Supabase Postgres + Edge Functions

Status: Reference

Production-readiness #14. Documents the per-client + per-project quotas that govern how the app uses Supabase, and the design decisions that keep us comfortably inside them.

Realtime status

The app currently opens zero persistent Supabase Realtime WebSocket connections. The previous Postgres-Changes hooks and realtime adapter were retired on 2026-06-01.

Current freshness mechanisms:

  • Live session screens: focus-gated 10s manual-invalidate polling in useLiveSessionData, stopping on terminal session status
  • DM: native query refetchInterval (thread 5s, inbox 15s)
  • Signals: push notification handlers plus foreground/refocus cache-bust
  • Ordinary surfaces: staleTime, optimistic writes, and targeted invalidation

Realtime quota monitoring is therefore not a launch capacity concern unless a future feature deliberately reintroduces socket hooks. If that happens, update this doc with current Supabase plan limits and measured channel counts before shipping.

Postgres connection ceiling

Supabase routes every PostgREST + RPC call through PgBouncer (transaction pool mode). That means the app's effective DB connection cost per HTTP request is one PgBouncer slot for the duration of the SQL transaction, not one Postgres connection.

Free + Pro plan limits

PlanDirect connectionsPooled connections
Free60200
Pro200400
Team4001,000

Source: https://supabase.com/docs/guides/database/connecting-to-postgres

Our shape

  • PostgREST + Edge Functions use pooled connections by default — every supabase.from(...).select() / supabase.rpc(...) borrows a pooled slot for the SQL transaction duration (~10-50ms typical).
  • Realtime currently uses no app sockets — there is no dedicated realtime connection cost in the client runtime.
  • Edge functions run in Deno isolates with their own client instances; each function invocation = 1 pooled slot for its duration. Our edge functions are fast (<2s) so slot dwell time is minimal.

Capacity math

  • Per request: 1 pooled slot × ~50ms dwell = ~20 req/s/slot.
  • Free plan (200 pooled slots) ≈ 4,000 req/s sustained.
  • Pro plan (400 pooled slots) ≈ 8,000 req/s sustained.
  • Real-world ceiling: bound by Postgres CPU long before pool exhaustion. Watch the Supabase dashboard → Database → CPU chart.

Code-level guardrails

  • 20-second client fetch timeout (client.ts) — a hung request can't hold a pooled slot indefinitely.
  • Server-side rate limit on heavy endpoints (00160 + 00070) — gates PIPA export (3/hr/user) and trusted-write RPCs (30/min/user for score submission, etc.).
  • Bulk fan-out hooks — every list-rendered component takes its data as a prop from a parent's single bulk query (Network Protocol A+B), so a 50-card feed costs 1 query slot, not 50.
  • limit(N) on every adapter SELECT — unbounded scans don't reach production (auditable via grep \.select\( without .limit().

Realtime publication history

Several tables were historically published to supabase_realtime during the Postgres-Changes era. Publication status is no longer the consumer contract: the client has no persistent realtime hooks. Treat any future realtime publication as a new freshness-tier decision, not as inherited behavior.

Monitoring

Production dashboards to watch:

  • Supabase → Database → CPU — primary bottleneck signal
  • Supabase → Database → Connection Pool Stats — % slots in use, wait time on slot acquisition
  • Sentry → Performance → tagged ota.channel — request latency p95 (the 20s timeout shows as network error: aborted if exceeded)

Upgrade signals

Trigger a plan upgrade when ANY of:

  • Concurrent users sustained > 80% of plan connection cap for >1 hour
  • Database CPU sustained > 70% over a 5-minute window
  • Slot acquisition wait time p95 > 10ms

These thresholds give ~25% headroom before user-visible degradation starts (timeouts firing, queries queueing).

Markdown remains the source of truth. Run yarn docs:check before handoff.