Your Lovable app ran fine at 1,000 monthly active users. The Supabase Pro tier felt like a steal at $25/month, the dashboard was green, latency was boring. Then you hit 10,000. The bill jumped, p95 latency went sideways, and one Tuesday at lunch the database went read-only for nine minutes while you stared at the Vercel logs.
This is the most predictable scaling cliff in the modern serverless stack. We see it every month when founders ask us to audit a Lovable codebase that "stopped working without anyone changing anything." Nothing changed: the user count did. And the defaults that ship with Lovable + Supabase are tuned for the 1K-user case, not the 10K one.
The good news: the fixes are mechanical, almost all of them keep you on Supabase, and the normal operating range is still small: roughly $150-$250/month for Supabase Pro, Small or Medium compute after credits, PITR, low-usage Redis, and a real job runner. That is not a ceiling, and 10K MAU is not what triggers it. It is the price of turning a 1K-user stack into a 10K-user stack without hiring a DevOps person.
Pebblely scaled to 1M users in seven months on Supabase Auth without leaving the platform. You almost certainly do not need to leave either. You need to harden.
This is for the CTO or technical founder sitting at the 1K-to-10K crossover. The work is straightforward: find what breaks first, fix twelve mechanical defaults, then decide whether Supabase still earns its place in the stack.
What "fine for 1,000 users" actually means
The default Lovable + Supabase setup is a React/Vite SPA calling the Supabase JS client directly from the browser, with row-level security as the only authorization boundary. Add Supabase Pro at $25/month, which includes $10/month in compute credits. That covers one Micro instance: 2-core shared ARM, 1 GB RAM, 60 max direct connections, 200 max pooler clients.
At 1,000 MAU this works because nothing is stressed. In the simple CRUD apps we audit, Micro is usually fine until query shape breaks it: the working set fits in memory, sequential scans are cheap because the dataset is small, and your concurrent connection count rarely tops a few dozen. Supabase returns up to 1,000 rows per request by default, which looks fine because no table you own has more than that yet.
Now scale to 10,000 MAU. Assume 15% DAU and roughly three sessions per daily user. That is 1,500 daily users and about 4,500-5,000 sessions per day. If a launch email, webhook burst, or AI crawler compresses that traffic into a lunch-hour spike, hundreds of concurrent requests are plausible. Your dataset crossed 100K rows in the busy tables months ago. None of the per-row math you got away with at 1K still works.
What actually breaks first
Five things, almost always in this order:
1. Row-level security goes quadratic. Lovable writes RLS policies that look correct and pass at 1K rows. At 100K they crawl. The reason is one specific footgun we will fix in the next section.
2. The direct connection pool saturates. A Vercel account can autoscale to 30,000 concurrent function executions. Your Supabase Micro instance has 60 direct connections. If server-side code talks to port 5432, the first real traffic spike exhausts the pool, and new requests block waiting for a connection, then time out, then look like a database outage in your logs.
3. The 1,000-row API ceiling bites without a product-level error. Lovable scaffolds select('*').eq('user_id', userId) everywhere, with no pagination. Supabase caps responses at 1,000 rows by default. Pages can render with missing data unless you paginate and ask for counts explicitly.
4. Cache hit ratio drops below 99%. Postgres starts hitting disk for queries that used to live in shared buffers. Latency doubles, then triples, with no obvious cause in the slow-query log.
5. Background work clogs the request path. Without a job runner like Trigger.dev or Inngest, every webhook, every email send, every async job tends to run inside a function tied to a user request. Long jobs stall short ones.
There are more, table bloat, lock contention, user-reported connection leaks around attachDatabasePool on Vercel Fluid, but those five drive the lunchtime outage. We will fix them in order.
Fixing RLS at scale
This is the single biggest performance footgun in Lovable-generated code, and the fix is one line of SQL per policy.
The standard Lovable policy looks like this:
CREATE POLICY "Users can read own posts"
ON posts FOR SELECT
USING (user_id = auth.uid());
auth.uid() is a function call. Postgres evaluates it once per row during the scan. On a 100K-row table, that is 100K function invocations per query. The query planner cannot cache the result because it does not know the function is stable from its call site.
Wrap the call in a SELECT and the planner inserts an initPlan node, evaluating the function exactly once and caching the result for the rest of the scan:
CREATE POLICY "Users can read own posts"
ON posts FOR SELECT
TO authenticated
USING (user_id = (SELECT auth.uid()));
Supabase's own RLS troubleshooting docs show the simple auth.uid() wrapping case improving from 179ms to 9ms, and a compound function policy going from 11,000ms to 10ms. The exact win depends on policy shape, but the direction is not subtle. We have measured similar numbers on three Lovable codebases this year.
Two more RLS rules to apply at the same time:
- Index every column used in an RLS policy, and every foreign key. Postgres does not auto-index FKs. In Supabase's 1M-row team-policy test, the unindexed version timed out past two minutes; the wrapped-and-indexed version ran in single-digit milliseconds. Use
CREATE INDEX CONCURRENTLYso production stays online while it builds. - Push multi-tenant join policies into a SECURITY DEFINER function. A policy that joins through a
team_usermembership table evaluates RLS on every joined row. Supabase'shas_role()example drops from 178,000ms to 12ms once the lookup is moved into a wrapped security definer function.
Add TO authenticated to every policy on authenticated endpoints so anon traffic skips evaluation entirely.
Connection pooling: stop talking to port 5432
The second cliff is connection exhaustion, and this is the one that takes you offline rather than slow.
Lovable's browser client is fine, it goes over PostgREST and shares one HTTP path. The trouble starts the moment you add server-rendered Next.js routes, Vercel Functions, or Drizzle/Prisma talking directly to Postgres. Each function instance can hold a connection. Vercel can autoscale to 30,000 concurrent executions. Your Micro instance has 60 direct connections. The math does not work.
The fix is Supavisor in transaction mode on port 6543, not direct on 5432.
Supabase's shared pooler is Supavisor; paid projects can still use a dedicated PgBouncer pooler when they need the lowest latency. The published benchmark numbers from Supabase's own engineering blog: a single 16-core Supavisor instance handles 250,000 concurrent connections; two 64-core instances clustered serve 1,003,200 simultaneous connections at 20,000 QPS, with p95 latency of 47ms at peak load. That is the pooler's benchmark, not your app's guarantee. You are nowhere near it.
Transaction mode acquires a Postgres backend connection only for the duration of a single transaction, then releases it. That stops 500 short-lived function invocations from becoming 500 backend Postgres connections. It does not erase every ceiling: Micro still has 200 pooler clients, Small has 400, and slow queries can occupy the backend pool long enough to queue traffic.
Transaction pooling buys concurrency, not slow-query forgiveness.
The trade-off: transaction mode disables session-level features. No prepared statements, no LISTEN/NOTIFY, no advisory locks across queries. Prisma and other database clients can be configured for pooler-safe behavior, usually by disabling prepared statements. Older code may need a small rewrite.
Pool sizing for a 10K-MAU app on Pro/Small (90 direct, 400 pooler clients):
- Direct backend pool: 30-50, leaving headroom for migrations, dashboard, and replication slots.
- Per-Vercel-Function client pool: 1. One client per cold invocation. Let Supavisor handle fan-in.
- Realtime/long-poll connections: count separately against the 500 included concurrent realtime connections on Pro.
If you are on Vercel Fluid and using attachDatabasePool against the Shared Pooler, read GitHub discussion #40671 first. It is a user-reported pattern, not a confirmed vendor root cause, but the symptom is worth checking: suspended functions can make pooler-client counts climb when you thought connections were idle. Pin to a per-project pooler or aggressively lower idle timeouts.
Caching: the cheapest fix with the fastest payoff
Lovable apps ship with zero caching. Every page load, including the marketing-style ones nobody is logged into, hits Postgres through PostgREST. At 10K MAU this is the single highest-ROI fix on the list.
Three layers, in order of payoff:
HTTP/CDN cache. For anything not user-specific, marketing pages, public profiles, product catalogs, leaderboards: revalidate: 60 on a Next.js route turns repeated traffic to the same public URL into roughly one origin fetch per minute per path. If those 10,000 page loads hit ten popular product pages, the database sees about ten origin fetches per minute. If they hit 10,000 unique profile URLs, you still need Redis, denormalized tables, or a different cache key.
Upstash Redis for read-through caching of expensive authenticated queries: dashboards, complex aggregations, denormalized views. The free tier covers small command volume, pay-as-you-go is priced by command count, and the fixed 250MB plan is $10/month. For small cache footprints it can stay under $10; for high-cardinality dashboards, price it before you promise it.
Materialized views for analytics and dashboards. The Prince Nzanzu scaling guide reports a dashboard load dropping from 12 seconds to 200ms after moving aggregate queries into a materialized view refreshed every five minutes via pg_cron. We have replicated similar numbers: a SaaS billing dashboard that took 8.4 seconds for the founder to load went to 180ms after one materialized view and one composite index.
The composite-index point is worth repeating. The same guide reports a query going from 4.2 seconds to 48ms with a composite (user_id, status) index plus a partial index WHERE status = 'published'. Both are lines of SQL, not architecture changes.
CREATE INDEX CONCURRENTLY idx_posts_user_status_created
ON posts (user_id, status, created_at DESC);
CREATE INDEX CONCURRENTLY idx_posts_published
ON posts (created_at DESC)
WHERE status = 'published';
Background jobs: get them off the request path
Lovable scaffolds nothing here. By 10K users you need scheduled cleanups, invoice generation, email sends, webhook fan-out, async AI inference, search indexing. None of those should sit on a user's request.
Three credible paths, depending on the job shape:
| Tool | Current cost shape | Best for | Limit to respect |
|---|---|---|---|
| Supabase Cron + pg_cron + Edge Functions | $0 inside the stack, plus normal Supabase usage | Cleanup, refresh materialized views, low-volume webhook fan-out | 8 concurrent jobs recommended, 10-minute job guidance, pg_cron can use up to 32 DB connections |
| Trigger.dev | Hobby $10/mo, Pro $50/mo, plus run and compute usage | TypeScript-native retries, schedules, queues, fan-out | Plan concurrency, queue, rate, schedule, and retention limits |
| Inngest | Hobby $0 with 50K executions and 5 concurrent steps; Pro starts at $75/mo | Event-driven durable functions and AI pipelines | Free tier is tight for busy production workflows |
For most teams the right shape is pg_cron for the in-database hygiene jobs (refresh views, expire sessions, archive rows) and Trigger.dev or Inngest for everything that needs retries, backoff, or DAGs. Supabase Edge Functions remain a strong default for LLM proxy calls and webhook handlers that return quickly. Current hosted limits are not a "150-second CPU wall": paid projects list a 400-second worker wall-clock duration, 2 seconds of CPU time per request, and a 150-second request idle timeout. If the job needs durable retries, a queue, or minutes of CPU-bound work, use a real runner.
The 12 mechanical fixes
Run these in order. We have used this exact list to harden three Lovable codebases this quarter, all of which stayed on Supabase.
Send this list to the engineer who owns your Supabase project and ask for a before/after slow-query screenshot. If they cannot produce one, you are still guessing.
| # | Fix | Why |
|---|---|---|
| 1 | Wrap every auth.uid() and auth.jwt() in (SELECT auth.uid()) | 179ms -> 9ms in the simple case; bigger wins in compound policies |
| 2 | Index every RLS column and every foreign key with CREATE INDEX CONCURRENTLY | Postgres does not auto-index FKs |
| 3 | Move all server-side DB access to Supavisor port 6543 (transaction mode) | Direct backend connections stop being the first bottleneck |
| 4 | Replace select('*') with explicit columns | Smaller payloads, fewer disk reads |
| 5 | Replace .range() deep pagination with cursor-based .lt(orderCol, lastSeen).limit(N) | Avoid the 1,000-row PostgREST cap and OFFSET cost |
| 6 | Replace N+1 client calls with relational embeds: select('id, title, author:profiles(name)') | One round trip instead of fifty |
| 7 | Move dashboard aggregates to materialized views refreshed via pg_cron | Precompute the expensive work |
| 8 | Add Upstash Redis or ISR caching in front of read-heavy public endpoints | Repeated public traffic hits cache per path, not Postgres |
| 9 | Move background work off the request path, pg_cron in-DB, Trigger.dev/Inngest off-DB | Removes the long-job-blocks-short-job pattern |
| 10 | Bump compute from Micro to Small or Medium (usually +$5-$50 net after compute credit) | Last fix, not first, software fixes first |
| 11 | Turn on PITR ($100/mo) | At 10K users a corrupt write is a business event |
| 12 | Turn on pg_stat_statements and index_advisor, review weekly | The slow-query log is your scaling dashboard |
That is the entire program. Most teams complete it in 1-2 weeks of senior engineering work.
Stay or leave: the honest verdict
For roughly 95% of teams at 10K MAU, the right call is stay on Supabase and harden it. The math:
- A realistic operating range is $150-$250/month, not a ceiling: Supabase Pro, Small or Medium compute after the $10 credit, PITR, low-usage Redis, and either Trigger.dev Hobby/Pro or Inngest Pro depending on job volume.
- That does not include Vercel usage, SMS or phone MFA, log drains, read replicas, observability tools, AI inference, storage growth, image transformations, or paid job-runner overages.
- That is well under the implicit $500-$1,000/month cost of a part-time DevOps engineer maintaining a self-hosted stack.
- You keep Supabase Auth, Storage, Realtime, and Edge Functions. None of those are trivial to reproduce. Pebblely proves the platform can survive much more than this; your bottleneck is probably policy, query, and pooling hygiene.
- The migration path is mechanical, not architectural. RLS rewrites, index adds, pooler switch, caching layer.
Leaving Supabase is justified when the reasons stop being about scale and start being about strategy:
- Your monthly bill consistently exceeds $500-$1,000 and is dominated by compute, not features. You are paying for a big instance, not for the platform.
- You have workloads Supabase does not do well, heavy analytical OLAP, sustained high-write workloads where the metrics point to a different architecture, regulated industries beyond what the Team tier covers, or hard lock-in concerns at the board level.
- You have outgrown PostgREST and want first-class GraphQL or gRPC.
If you do leave, the typical destinations are AWS RDS, Neon, Crunchy Bridge, or a self-hosted Postgres on Hetzner with Coolify. Raw VM spend can look cheaper around $150-$200/month, but total cost usually does not cross over until much later because the move adds 5-10 hours per month of DevOps work to someone's plate. We have run that migration; we usually do not recommend it before $500/month.
The decision tree is short. If your bill is under $250/month and your problem is latency or outages, harden. If your bill is under $500/month and your problem is feature gaps, harden and add the missing piece (Upstash, Trigger.dev, a read replica). If your bill is over $500/month and dominated by compute, or you have a strategic lock-in concern, plan a migration, and do not start by ripping out Supabase Auth.
What to do this week
Pick the worst-performing query in your Supabase dashboard. Wrap its auth.uid() calls in (SELECT auth.uid()). Add the missing FK index. Switch one Vercel Function to port 6543 and watch both backend connections and pooler-client counts. Measure the difference. You will probably buy yourself another six months of runway in an afternoon.
If you would rather hand the audit to a team that has done this twelve times, we run a one-week scaling-readiness audit on Lovable + Supabase codebases, RLS rewrite plan, pooler config, caching plan, background-job plan, cost range, and the migration call, delivered as a PR-ready checklist. That is what our engineering practice does. Book a call and we will look at your slow-query log together.
The default Lovable + Supabase stack is genuinely good. It just was not configured for the user count you have now. Fix the twelve things above and it will carry you to the next milestone.