Introduction
It was fine last month. Then people actually showed up, and now there is a word in your logs you have never had to read before. Timeout. Or an endpoint that used to answer in nine milliseconds is taking two seconds, and nothing in the code changed. Only the load changed, and you have never sized a database in your life.
The good news is that this is a much smaller problem than it feels like at midnight. Supabase is Postgres. It is not a toy database with a growth ceiling built in, and the thing that just broke is almost certainly not the platform. There are three walls a Supabase app runs into, they arrive in a fixed order, and the first one is a configuration problem that most teams can fix in an afternoon.
The order matters more than any individual fix, so here it is up front. Connection exhaustion comes first. Query shape comes second. Genuine architectural limits come a distant third, and most products never reach them at all. Nearly everyone who tells us they have outgrown Supabase turns out to be standing at wall one, which is why this page is ordered the way it is rather than as a list of features.
Is Supabase production ready? For most products, yes. The question that actually helps is which of three walls you are at, because the fixes are completely different and only one of them costs money.
- Wall one, connections. Timeouts under load, not slowness. A Postgres connection is an OS process, so the supply is small and fixed. Fixed by routing traffic through the pooler, not by buying compute.
- Wall two, query shape. Real slowness that grows with your data. Usually an unindexed column inside a Row Level Security policy turning a read into a full scan.
- Wall three, architecture. Rare. Sustained writes past what one primary can absorb, a residency boundary the platform cannot draw, or a workload that was never relational.
- Upgrading compute is the instinctive fix and usually the wrong one. Across the entire range of instance sizes, direct connections go from 60 to 500. Pooler clients go from 200 to 12,000.
One thing before we start, because it should change how you read the rest. If your logs have no timeouts in them and your slowest endpoint is comfortably fast, you do not have a scaling problem and nothing below applies to you yet. Skip to the section on who should not change anything, do the three things in it, and get back to building. We would rather you did that than hired anybody, including us.
Everything factual on this page is taken from Supabase's own current documentation and linked to it, rather than written from memory. Numbers on this topic go stale quickly and a confident wrong number is worse than no number.
Is Supabase Production Ready?
Yes, for the large majority of web and mobile products, and the reason is unglamorous. Underneath the dashboard, the auto-generated API and the nice auth flow, you are running Postgres. Postgres has been carrying serious production load for three decades. Nothing about putting a friendly console in front of it makes it worse at that job.
The reason the question keeps getting asked and keeps getting bad answers is that it is the wrong question. Production ready is not a property a platform has or lacks. It is a relationship between a specific workload and a specific configuration, and the honest version is narrower: which wall is my app standing at, and is that wall made of configuration, of query plans, or of physics.
Those three are worth separating because the cost of each is wildly different. A connection problem is free to fix and takes an afternoon. A query problem costs a day or two of somebody who can read an execution plan. An architectural problem is the only one that genuinely costs a project, and it is also the rarest by a long way.
So the useful move is diagnostic rather than philosophical. Do not ask whether Supabase can scale. Ask what your app runs out of first. That is answerable today with the data you already have, and the answer tells you exactly what to do next and, more importantly, what not to do.
We have run this diagnosis on Postgres-backed products well past the point where people assume you need something exotic. The EdTech platform our team built and scaled serves more than 250,000 daily users, and an exam platform we worked on peaks past 10M requests a minute. Neither of those got there by picking a fashionable database. They got there by being honest about which wall was in front of them at the time.
How Many Users Can Supabase Handle?
There is no honest number, and anyone who gives you one is selling something. Users are not the unit that runs out. Concurrent database connections are, and those have almost nothing to do with how many people have signed up.
Think about two apps with a hundred thousand registered users each. One is a reporting tool where people log in on Monday morning, pull a dashboard and leave. The other is a chat product where every open tab holds a subscription. The first might never trouble a small instance. The second can exhaust connections with a few hundred people online. Same user count, completely different database.
So replace the question with arithmetic you can actually do. Your peak concurrent connections are roughly the number of running application instances multiplied by the connection pool size each one keeps open, plus anything long-lived like realtime subscriptions, plus whatever your background jobs and migrations hold. Write that number down. That is the number that matters.
Now compare it to the ceiling. Supabase publishes a table of connection limits per compute size, and the shape of that table is the single most useful thing on this page.
Compute size Direct connections Pooler clients
Nano 60 200
Small 90 400
Medium 120 600
Large 160 800
XL 240 1,000
2XL 380 1,500
4XL 480 3,000
8XL 490 6,000
16XL 500 12,000Read the two columns against each other, because that comparison is the whole lesson. Going from the smallest instance to the largest one available takes direct connections from 60 to 500. That is about eight times, across the entire range of machines on offer, from a shared core to 64 dedicated ones. Over that same range, pooler client capacity goes from 200 to 12,000. That is about sixty times.
You cannot buy your way out of connection exhaustion. The direct connection ceiling barely moves no matter how much machine you put underneath it, because each connection is an operating system process holding memory, and there is only so much of that to hand out. The pooler is not an optimisation you add later when things get serious. On any app that opens connections dynamically, it is the thing that makes the numbers work at all.
This is worth sitting with for a second if your instinct on Friday night was to click the upgrade button. That instinct is what the dashboard invites, it feels like doing something, and against the most common failure it buys you very little.
Why Do Timeouts Arrive Before Slow Queries?
Because connections are scarce and requests are not. This is the mechanism section, and it is worth understanding properly rather than pattern-matching, because once you have it the right fix is obvious every time.
A Postgres connection is not a lightweight handle. Every one is a separate backend process on the server with its own memory. That design is a large part of why Postgres is as reliable as it is, and it means the number of connections available is small, fixed, and roughly independent of how fast your CPU is. A few hundred, in practice. Meanwhile, an HTTP request costs you nothing to create and your traffic is bounded only by how well your launch went.
So the two curves cross early. Long before you saturate CPU, long before any individual query is slow, you run out of slots. Every request that arrives after that waits for one, and waiting looks exactly like a timeout from the client. The database is not struggling. It is fully booked.
Supabase solves this the way everyone solves it, with a pooler in front of the database. Their connection documentation gives you three ways in, and choosing correctly between them is most of the job.
Direct connection, port 5432. Straight to Postgres. Supabase describes it as ideal for persistent servers, virtual machines and long-lasting containers. If you run a fixed number of app processes that live for days, this is fine and it is the simplest thing that works.
Transaction mode, port 6543. A client borrows a connection for the duration of one transaction and gives it straight back. Supabase describes this as ideal for serverless or edge functions, which need many transient connections. This is the mode that turns 500 real connections into thousands of concurrent clients.
Session mode, port 5432 through the pooler. The connection is held for the life of the client session. Useful for the stateful things transaction mode cannot do, which mostly means migrations and admin work.
The shared pooler is Supavisor, which Supabase describes as multi-tenant, available on every project and IPv4-only. That last detail catches people out on IPv6-only networks and it is worth knowing before you spend an evening on it.
Here is where serverless quietly multiplies the problem, and it is the most common version of this we see. Each running instance of your function keeps its own connection pool. That is sensible in isolation. Then your platform scales you to forty concurrent instances under load, each holding five connections, and you have asked for two hundred connections from an instance that has ninety. Nobody wrote a bug. The architecture simply multiplied a number that nobody was watching.
The fix is to route that traffic through transaction mode so those transient clients share a small pool of real connections, and to keep direct connections for the small number of long-lived processes that genuinely need them. That is configuration. It costs nothing and it is usually the entire incident.
If you are reading this at midnight with an incident open, check the port on your connection string first. If your serverless application is talking to 5432, you have found it. Move it to 6543, redeploy, and read the rest of this tomorrow.
Why Does Prisma Time Out When Nothing Else Does?
Because transaction mode does not support prepared statements, and Prisma uses them by default. That is the whole answer, and it deserves its own section because it produces errors that look like a network fault, sends people hunting through their infrastructure for a day, and has nothing to do with their infrastructure.
Supabase states the limitation plainly in the connection documentation: transaction mode does not support prepared statements. A prepared statement is parsed and planned once, then executed many times against the same connection. Transaction mode hands your connection to somebody else the moment your transaction commits, so the thing you prepared is not there when you go looking for it.
The fix is documented and it has two halves. Supabase's Prisma guide says to point DATABASE_URL at the transaction mode string on port 6543 and to append pgbouncer=true to it so the client stops issuing prepared statements. Then create a separate DIRECT_URL variable pointing at port 5432 and use that for migrations, because a migration is exactly the kind of stateful operation transaction mode is bad at.
Miss the first half and your queries fail intermittently under load. Miss the second half and your application runs beautifully until the day a deploy needs to run a migration, at which point it fails in your release pipeline with an error that appears to be about something else entirely.
The generalisation is worth carrying beyond Prisma, because every ORM has a version of this. A pooler in transaction mode is not a transparent proxy. It changes what a connection is allowed to remember between statements. Anything your client library assumes is sticky, which means prepared statements, session variables, temporary tables, advisory locks and LISTEN or NOTIFY, needs checking against that assumption. Most libraries have a documented flag. The bugs come from not knowing you needed to look.
Two other things to check while you are in there, because they cost nothing and they explain a lot of otherwise confusing behaviour. Your ORM has its own pool size, and that number gets multiplied by your instance count before it ever reaches Supabase, so a default of ten on twenty instances is a request for two hundred connections. And your statement timeout should be set deliberately rather than left wherever it landed, because a runaway query holding a pooled connection is a much more expensive event than a runaway query holding a dedicated one.
What Makes a Query Slow as Data Grows?
Wall two, and it feels different from wall one in a way that is diagnostically useful. Connection problems appear suddenly under concurrency and go away when traffic drops. Query problems creep. The endpoint that was fine at ten thousand rows is sluggish at two hundred thousand and painful at two million, and it is just as slow at three in the morning with one user online.
On Supabase specifically, the most common cause is not a missing index on the obvious column. It is Row Level Security, and this catches good engineers because RLS is filed mentally under security rather than under performance.
Here is the mechanism. An RLS policy is a condition Postgres applies while deciding which rows you are allowed to see. It is not a gate at the door that runs once. It is closer to a WHERE clause the planner has to satisfy against candidate rows. So the cost of your policy is paid on every read, and it scales with the amount of data the planner has to consider.
Supabase's own Row Level Security guide names three fixes, and in our experience they account for most of the recoverable slowness on a Supabase read path.
Index every column your policies filter on. Supabase puts it directly: Postgres evaluates the policy against each candidate row, so an unindexed filter column turns a read into a sequential scan. If your policy filters on tenant_id or owner_id, that column needs an index the same way any hot query column does. It is easy to miss because you never wrote the query yourself, the policy did.
Wrap auth functions in a select. Writing the auth call inside a select causes the planner to build an initPlan, which lets it cache the result for the whole statement instead of calling the function once per row. On a table of any size the difference between per-statement and per-row is not a tuning detail, it is the difference between a fast query and an outage.
Name the role in the policy. Adding a role clause means the policy stops executing for roles it does not apply to. Supabase gives the anonymous case as the example: execution stops at the role check rather than running policy logic for a user who was never going to match.
None of this means turning RLS off. Do not do that. RLS is the thing standing between one customer and another customer's records, and switching it off to make a page load faster is trading an outage you can fix for a breach you cannot. If you want the security treatment rather than the performance one, our guide to vibe coding security covers what RLS is actually protecting you from and how to check whether your app is currently leaking.
Beyond RLS, the boring advice is the correct advice and it has not changed in twenty years. Find your slowest statements from the database rather than guessing from the application. Read the execution plan before you touch anything. Index for the query you actually run rather than the one you imagined. Watch for the read that got called once per item in a loop, which is the single most common way a fast page becomes a slow one without anybody editing a query.
Who Should Not Change Anything Yet?
A lot of you, and this section is here because the alternative is watching people spend money and weekends on a problem they do not have.
If your logs contain no timeout errors, your slowest endpoint is comfortably fast, your traffic pattern is steady rather than spiky, and you run on a small number of long-lived servers rather than serverless functions, then you are not near any of these walls. Upgrading your instance will change nothing you can measure. Rearchitecting will cost you weeks you should be spending on the product.
Do three things instead, none of which take an afternoon, and then leave it alone.
First, turn on point in time recovery if your database has grown past 4 GB. Supabase recommends exactly that in its production checklist, and recovery is the one thing you cannot retrofit after the day you need it.
Second, add an index on every column your RLS policies filter on. Do it now while the tables are small and it is a thirty second migration. Do it later and it is a lock on a busy table during an incident.
Third, run one load test against a staging project rather than against production. Supabase's checklist recommends load testing on staging, and the point is not the number you get. The point is that you find your ceiling deliberately, on a quiet Tuesday, instead of discovering it during the first hour of the only launch you get.
While you are in the checklist there are two limits that surprise people, and both are cheap to handle in advance. The built-in auth mailer is rate limited to two emails an hour and is meant for development rather than for real signups, so a custom SMTP sender is not optional once real users exist. And projects on the free plan can be paused after seven days of inactivity, which is a fine trade for a prototype and a genuinely bad surprise for a demo environment somebody is about to show a customer.
If you did those three things and everything above still describes somebody else's app, close this tab. You do not need us. Come back when the logs change.
Do You Have to Leave Supabase to Scale?
Almost certainly not, and if somebody opens the conversation with a rebuild you are entitled to ask which specific limit they are naming. Not which platform they prefer. Which limit.
We should be straight about the incentive here, because it is the reason this advice is hard to find. A development partner that recommends a migration to new infrastructure gets a large, well-defined, several-month project out of it. A development partner that recommends changing a port number, adding four indexes and rewriting three policies gets a fortnight. The second one is usually the right answer and it is nobody's favourite proposal.
So here are the three cases where leaving is genuinely correct, stated as narrowly as we can make them.
One, sustained write throughput past what a single primary can absorb. This is the real ceiling and it is worth being precise about why. Read replicas serve SELECT queries only, and replication is asynchronous, which means a replica lags the primary and your application has to be comfortable reading slightly stale data. They are excellent for heavy analytical reads and for serving users in a distant region. They do nothing for writes. Every insert still lands on one machine, and there is no managed sharding to reach for when that machine is full.
Two, a boundary the platform cannot draw for you. A specific data residency obligation, a contractual isolation requirement, a network topology your customer's security review insists on. These are real and they are not negotiable by being clever with indexes. Note that this is a compliance decision rather than a performance one, and it should be made by reading the obligation rather than by reading a benchmark.
Three, a workload that was never relational. Very high volume append-only telemetry, full-text search at a scale that wants a dedicated engine, vector workloads past what an extension comfortably carries. The usual answer here is not to leave Postgres, it is to move that one workload somewhere purpose-built and let Postgres keep doing the part it is good at.
Notice what is not on that list. Getting popular is not on that list. A slow dashboard is not on that list. A bill that grew is not on that list, and is usually a query problem wearing a costume.
Self-hosting deserves its own paragraph because it is the most commonly proposed escape and the most commonly regretted one. It is a staffing decision, not a savings one. Supabase is direct about what you give up in its self-hosting documentation: no branching, no advanced metrics beyond logs, no managed backups and point in time recovery, no analytics and vector buckets, no ETL, no platform management API, and an interface that does not support multiple organisations or projects. Support becomes community based. You take on provisioning, security hardening, Postgres maintenance, high availability, disaster recovery, monitoring and backups. If nobody on your team owns that as their actual job, self-hosting does not make you more reliable. It makes you the on-call engineer for a database you were previously renting expertise on.
The version of this we would actually recommend to most teams is boring. Stay where you are. Fix the connections, fix the policies, move the one workload that does not belong in Postgres out of Postgres, and revisit in a year with real numbers.
What Does a Scaling Check Look Like?
In order, and the order is the point. Every step below is cheaper and more reversible than the one after it, so doing them out of order is how teams end up paying for compute that fixed nothing.
1. Measure before you change anything. Get your slowest statements from the database itself rather than from application traces, and get your actual peak connection count rather than your assumed one. If you cannot state both numbers, every decision after this is a guess with a bill attached.
2. Work out which wall you are at. Timeouts that appear under concurrency and vanish when traffic drops are wall one. Slowness that tracks table size and is present with one user online is wall two. If it is neither, you have an application problem rather than a database problem, and the database is the wrong place to look.
3. Get the connection routing right. Transient and serverless traffic through transaction mode on 6543, long-lived servers on direct connections, migrations on a direct URL. Set your ORM pool size deliberately and multiply it by your instance count to check the arithmetic. Add the pooler flag your client needs. This is free and it resolves most wall-one incidents outright.
4. Fix the top three query shapes. Not thirty. Three. Read the plan, add the index the plan asks for, and re-measure. Query work has a very steep curve where a small number of statements account for most of the pain.
5. Index every column your policies filter on, and wrap your auth calls in a select. These are two specific, mechanical changes with a large effect and near-zero risk, and they are the most commonly skipped items on this list.
6. Only now consider compute. After the four steps above, a bigger instance buys you something real, because at that point you have a workload that is genuinely doing work rather than a workload that is queuing. Before those steps, you are paying to make waiting more comfortable.
7. Read replicas last, and only for reads. If your reporting queries are competing with your product for the same database, move them. Make sure the code doing the reading tolerates replication lag, because it will encounter it.
Most teams find their answer somewhere in steps 3 to 5, which is the reason this page exists in the order it does rather than opening with an architecture diagram.
If you would rather not run that on your own while the incident is live, that is exactly the kind of work our team does with clients, and you can start a scoping conversation whenever it is useful. What we would want to look at first is your connection arithmetic and your slowest three statements, in that order, because that is where the answer usually is.










