Scaling a Supabase App Past Its First Thousand Users

SprintX Team

Written By

SprintX Team

AI & Product Engineering

August 12, 2026

7 min read

An engineer inspecting Postgres query performance dashboards for a growing application

The specific things that break when a Supabase app goes from fifty users to a few thousand — and the fixes that work, in the order they are worth doing.

At fifty users your Supabase app felt instant. At twelve hundred, the dashboard takes six seconds to paint, one endpoint times out on Mondays, and every so often the whole thing returns "remaining connection slots are reserved" and then quietly recovers.

Nothing about your code got worse. You just crossed the line where Postgres stopped being able to hide your query patterns behind a small table. The good news is that scaling a Supabase app past its first thousand users is not a rewrite — it is four or five specific, boring fixes, and they almost always come in the same order.

Here is what actually breaks, how to confirm it rather than guess, and what to do in each case.

Find the real bottleneck before you change anything

You have exactly one job in the first hour: turn "the app is slow" into "this query, this many times per request, this many milliseconds each."

Supabase ships with pg_stat_statements enabled, and the Query Performance page in the dashboard reads straight from it. Sort by total time, not mean time. The query that takes 40ms but runs 600 times per page load is a bigger problem than the 900ms report nobody opens. Then take the top three offenders and run EXPLAIN ANALYZE on them with realistic parameters — not with the row you happen to own, which is often the fastest possible case.

What you are looking for is any Seq Scan on a table with more than a few thousand rows, and any node where the estimated row count and the actual row count differ by an order of magnitude. Those two signals explain most of what people diagnose as "Supabase got slow."

If the numbers come back fine and the app still feels slow, the problem is above the database — waterfalls of sequential requests, oversized payloads, no caching. Our walkthrough of why a website feels slow covers that side, which is often true at the same time.

Indexes: the first 80% of the fix

AI-generated schemas almost never include indexes beyond primary keys. That is fine at 500 rows and fatal at 500,000.

Index every column you filter, join, or sort on in a hot path. In a typical multi-user SaaS that means the tenant or owner column on every table, the foreign keys you join through, and the created_at you order by. Where you filter on two columns together — organization plus status is the classic — a composite index in that order beats two separate ones, because Postgres can only use one index efficiently per scan in most plans.

Two things people get wrong here. First, indexes are not free: each one slows writes and takes disk, so index the queries you actually run rather than every column. Second, a partial index is often dramatically better than a full one — if 95% of rows are archived and every query filters on active, index only the active rows and the index stays small enough to live in memory.

Create them concurrently in production so you do not lock the table while it happens.

RLS policies are queries, and they run on every row

This is the Supabase-specific trap, and it is the one that surprises people most.

A row-level security policy is a predicate Postgres evaluates while scanning. If your policy calls auth.uid() directly, that call can be re-evaluated per row. Wrapping it — writing (select auth.uid()) instead of auth.uid() — lets the planner treat it as an initPlan and evaluate it once. On a large table this single change routinely turns seconds into milliseconds.

The other rule: index the columns your policies reference. A policy that filters on user_id is worthless for performance if user_id has no index, because every read now scans the table to prove you are allowed to read three rows of it.

If your policy has to join through another table to decide access — checking membership in an organization, for example — pull that logic into a stable SQL function marked SECURITY DEFINER and call the function from the policy. It keeps the policy simple, avoids recursive policy evaluation, and gives Postgres something it can cache. Our deeper guide to Supabase row-level security and roles covers the correctness side of the same policies, and the RLS mistakes that show up in AI-built apps covers the ones that are actively dangerous rather than just slow.

Connections: the failure that looks like an outage

Postgres allows a fixed number of direct connections based on your instance size — small instances are in the low dozens, not hundreds. Serverless functions do not respect that. Every cold start opens its own connection, holds it, and never gets around to closing it.

Use the pooler. Supabase's Supavisor sits in front of Postgres, and for serverless workloads you want transaction mode (the pooled port), not session mode. Transaction mode hands a connection back after each statement instead of holding it for the life of the client, so a hundred concurrent function invocations can share a small pool.

The cost is that transaction mode does not support prepared statements or session-level state, so if you use Prisma or a similar client, you need the flags that disable prepared statements on the pooled connection. Long-running servers with their own pool — a Node process on a container — should connect in session mode instead. Getting this one setting right fixes more "random production outages" than any amount of query tuning.

Symptom to cause, in the order they usually appear

What you seeUsual causeThe fix
One page slow, rest fineMissing index on a filter or sort columnAdd the index, verify with EXPLAIN ANALYZE
Everything slow for logged-in users onlyRLS policy re-evaluating auth.uid() per rowWrap in (select auth.uid()), index policy columns
Intermittent connection errors under loadServerless functions exhausting direct connectionsMove to the pooler in transaction mode
Slow only at high traffic, queries look fineToo many round trips per requestBatch reads, add a cache layer
Dashboard slow, list pages fineAggregates over the full table on every loadMaterialize counts or roll them up on write
Storage-heavy pages slow globallyUncached originals served per requestServe through the CDN with long cache headers

What to do after the easy wins

Once indexes, policies, and pooling are handled, most apps have enough headroom to reach five figures of users. Beyond that the work changes shape.

Stop counting things live. A dashboard that runs six COUNT queries across the full table on every load will not survive growth, no matter how well indexed. Maintain counters on write, or refresh a materialized view on a schedule and read from that.

Cache the things that do not change per user. Reference data, pricing tiers, feature configuration, anything read constantly and written rarely — those belong in a cache, not in a database round trip. Choosing a caching strategy covers where the boundary sits.

Move slow work out of the request. Report generation, PDF rendering, email sends, and model calls do not belong in the path between a user's click and their page. Push them to background jobs and return immediately.

And add read replicas last, not first. Replicas solve a read-throughput problem. If your real problem is a missing index, a replica just gives you two slow databases and a replication lag bug to debug.

Frequently asked questions

At what point does Supabase stop being enough? Later than most people assume. It is Postgres with a good API layer in front, and Postgres comfortably handles workloads far past a few thousand users on modest hardware. What forces a change is usually a specific access pattern — heavy analytical scans, very high write throughput on one table — not a user count. Fix the queries before you consider leaving the platform.

Should I move off RLS for performance? No. Moving authorization into application code trades a solvable performance problem for an unsolvable security one, since every new endpoint becomes a chance to forget the check. Optimize the policies instead: wrap the auth calls, index the columns, and push complex checks into a SECURITY DEFINER function.

Do I need a bigger instance? Only after profiling. Upgrading compute raises the connection limit and adds memory for caching, which papers over a missing index for a while — at monthly cost, forever. Add the index, then decide whether the larger instance is still worth it. Usually it is not.


If your Supabase app got slow the same month it got popular, the cause is almost certainly identifiable in an afternoon rather than mysterious. SprintX profiles the database, fixes the queries and policies that are actually costing you, and leaves you with the measurements so you can tell next time. Send us your project details and we will tell you where the ceiling really is.

Related Articles

Contact us

to find out how this model can streamline your business!