10x the latency before the load test even starts: what k6 reveals in a "production-ready" Lovable app

June 9, 2026engineering12 min read

A founder messaged us last month with the same line we hear every few weeks. "We shipped it in two weeks on Lovable. Staging is flawless. We launch Tuesday." Then the screenshot: 50 virtual users in k6, p95 north of five seconds, a wall of 504s, Postgres CPU pinned at 96%.

Lovable is genuinely good at the first ninety percent, getting an idea from a Figma sketch into a working React/Vite app with Supabase behind it faster than a senior team can spin up a repo. The trouble is the last ten percent. Production shipping is a different discipline, and the export that survived staging can fall over the first time real traffic shows up. The good news: fixing it is usually targeted repair, not a rewrite.

This post is the build-log of what breaks, in what order, and the ten changes that took one anonymized appssemble rescue from cratering at roughly 30 RPS to clearing roughly 220 RPS with sub-100ms p95, without upgrading a single piece of compute.

What you're actually shipping when you ship a Lovable export

A typical Lovable export in 2026 is React + Vite + TypeScript, with Tailwind, shadcn/ui, and Radix UI in the component layer. For persistence and auth, the app usually sits on Lovable Cloud: a Supabase-backed managed backend, or on a Supabase project the founder owns directly. The Vercel and Next.js details in this post come from the exported or migrated apps we see after teams put the frontend on Vercel, wrap the app in Next.js, or add API routes around the generated client.

The risky pattern is not the frontend framework. It is browser-side data fetching, unpaginated Supabase reads, generated RLS policies with no supporting indexes, missing cache headers, and no rate-limit boundary between real traffic and Postgres.

Each of those defaults is reasonable in isolation. Stacked together, they form a stack that performs beautifully for one user and falls apart at fifty. The ceilings are not opinions, they are documented limits. A Micro Supabase instance allows 60 direct Postgres connections and 200 client connections through the Supavisor pooler. Separately, PostgREST has its own database pool; it returns 504 / PGRST003 when that pool cannot acquire a connection before db-pool-acquisition-timeout. Supavisor's client cap and PostgREST's pool timeout are different limits, but the operational lesson is the same: reduce query time, reduce request count, and stop fanning out tiny reads.

This matters because the cost of finding out in production is asymmetric. A failed launch costs you the launch, and on a select('*') heavy schema, it can cost you a four-figure bandwidth bill on the way down.

What breaks first, in the order it breaks

Run a five-minute, 50-VU steady-state ramp at the Lovable exports we audit and the failures arrive in a predictable sequence. The exact RPS depends on response time, think time, and whether the script batches requests, but the same four dominoes keep falling.

1. Cold starts and idle scale-down. Vercel scales functions down when traffic disappears, and the next request can pay a cold-start penalty. Archived functions are a longer-window case in Vercel's docs, within two weeks for Production deployments and 48 hours for Preview, not a fifteen-minute timer. The user-facing symptom is simpler: the first request after quiet traffic is slower than the warm path.

**2. useEffect waterfalls.** In the Lovable exports we audit, dashboards often render parent → child → grandchild, and each level fetches in its own effect. Three sequential 200ms requests gives you 2-3 seconds of page-ready time before the backend has felt any load at all. Add network jitter and you're at six seconds with zero VUs.

3. PostgREST pool-acquisition timeouts. When PostgREST cannot borrow a database connection from its own pool before db-pool-acquisition-timeout, it returns 504 / PGRST003. Supavisor's 200-client Micro cap is a separate ceiling for direct or pooler database clients. You can hit either limit under fan-out, and neither one degrades gently.

4. RLS sequential scans burning Postgres CPU. The generated policy pattern we often see, using ( auth.uid() = user_id ), calls auth.uid() per row and, without an index on user_id, runs a full table scan on every authenticated read. EXPLAIN shows Rows Removed by Filter: 50000+ on tables of any size. Once the pool is even partially backed up, per-query CPU cost is what tips Postgres into 100% saturation.

The pattern operators see: rising p95, then a wall of 504s, then pg: too many connections for role, then full-CPU saturation where even the formerly fast queries time out.

Where the breakage actually lives: five database failure modes

Most of the work sits in the database, not the runtime. The five recurring failure modes in roughly the order they hurt:

1. RLS policies with no supporting index. Supabase's own RLS docs show a policy-column index taking a benchmark from 171ms to less than 0.1ms. It's the cheapest win in the entire stack.

2. Stable functions called per row. auth.uid() is stable but Postgres calls it per row unless you wrap it: using ((select auth.uid()) = user_id) lets the planner run it once per query as an initPlan. Supabase documents this as an RLS performance recommendation; its benchmark drops from 179ms to 9ms after wrapping auth.uid(). In our rescues, that is often a double-digit win on selects with limit/offset/order by.

3. N+1 from PostgREST clients. The classic Lovable pattern, fetch posts, then loop and fetch each author, turns 1,000 posts into 1,001 round trips. The fix is PostgREST's relational embedding (select=*,author:profiles(*)), which requires the foreign key to actually exist. Lovable apps frequently store IDs as plain uuid columns with no FK, defeating embed inference.

**4. select('*') returning fat columns.** Lovable's generated CRUD defaults to select('*'). For tables with markdown, JSON, or image data, a 20-row list page can transfer 200KB+ per request. At 100 RPS that's 20MB/s of egress, enough to tilt the bandwidth bill on its own.

5. Pool exhaustion under serverless fan-out. A pg client configured with max: 5 looks safe in dev. Under serverless load, ten execution environments can each open their own pool of 5 → 50 concurrent connections, close to the direct-connection ceiling on Micro before PostgREST, Auth, and other services get a turn. The same pattern hits any Lovable app that uses postgres or Drizzle directly instead of going through Supavisor.

Edge and function failures you won't see in dev

Imagine you're a founder watching the launch from a coffee shop in Brooklyn. Your Vercel functions run in iad1 by default. Your Supabase project lives in Frankfurt because that is what someone picked during setup. Every server-side Supabase call now travels Brooklyn → Virginia → Frankfurt → Virginia → Brooklyn. We've seen this exact class of misconfiguration turn a sub-250ms direct client call into multi-second server-side latency. The fix is one dropdown, but only if you measure it before launch.

Then there's the function-multiplication problem. Vercel bundles Next.js and SvelteKit dynamic code into the fewest functions it can, so "thirty Next.js routes equals thirty functions" is not the rule. Direct api/ files and some non-framework deployments do map one-to-one, though, and that is where small exported apps accidentally create a large cold-start surface.

Concurrency adds a second layer. Vercel Functions can auto-scale up to 30,000 concurrency on Hobby and Pro, with burst behavior that ramps during traffic surges. Fluid Compute can run multiple invocations inside the same instance, which helps I/O-heavy work. It does not fix your own sequential code: a server action doing four Supabase calls in series still waits for call one, then call two, then call three, then call four.

Finally, rate limits. Most Lovable apps ship with no rate-limit middleware, and PostgREST itself has no built-in rate limiter, it's still tracked as an open issue. The first abusive client, or one accidental infinite loop on the frontend, is enough to consume the database/API capacity you thought was reserved for real users.

The cost curve nobody saw coming

Failures that don't show up as latency show up on a Stripe charge. A useEffect-driven dashboard can rack up four to six function invocations or Supabase API calls per page view: auth check, dashboard data, preferences, notifications. Invocation pricing alone is rarely the scary part, and Vercel's Active CPU meter excludes time spent waiting on database I/O. The cost risk is the combination: more invocations, provisioned memory while requests are in flight, repeated uncached reads, and transfer from oversized payloads.

Bandwidth follows the same shape. select('*') returning 20 rows at 50KB each = 1MB per dashboard load. 100,000 page loads = 100GB. Past the 1TB included Pro tier, that's $0.15/GB; a single launch spike on a media-heavy table has produced four-figure overages on the Vercel forums.

The most expensive mistake is the panic fix. When pool exhaustion hits, the easy lever is upgrading Supabase compute. Micro is roughly $10/month; 4XL is now roughly $960/month. Each step raises the connection and resource ceiling, but it does nothing for the underlying RLS sequential scan. We've watched founders pay the larger bill before someone added the index that let them drop back to a smaller tier.

The fixes: ten targeted changes, before and after

None of these require rewriting the app. They are small, boring, and they compose. Here is one anonymized appssemble rescue, measured on a Supabase Micro project with a dashboard route returning 20 tasks. The k6 run used a 50-VU steady-state profile with 1-4 seconds of think time and a dashboard mix of list, task, notification, and auth reads. The "after" run included an index on user_id, an RLS rewrite to (select auth.uid()), explicit column lists, pagination, PostgREST embedding for the join, and a server route that collapsed four client fetches into one parallel backend call.

MetricBeforeAfter
p50 latency240 ms38 ms
p95 latency1,850 ms92 ms
p99 latency5,400 ms (with 504s)180 ms
Error rate at 50 VU6.4 %0.0 %
Cliff RPS (where p95 doubles)~30 RPS~220 RPS
Postgres CPU at 50 VU96 %11 %

One afternoon. No rewrite. No compute upgrade. The full ten-fix list, in the order we apply them:

  1. Add the indexes RLS implies. For every policy referencing column = auth.uid(), add create index … on table (column). Single biggest p95 win in most rescues, 10-100x.
  2. Wrap auth functions in RLS policies. Rewrite auth.uid() = user_id to (select auth.uid()) = user_id so Postgres calls the function once per query, not once per row.
  3. **Replace select('*') with explicit column lists.** Cuts payload and parsing time. Often shrinks egress 5-10x.
  4. **Add range() pagination everywhere there is a list.** Default page size 20-50.
  5. Replace N+1 client-side joins with PostgREST embedding. Add the missing foreign keys first, then use select=*,author:profiles(*).
  6. Push fan-out fetches to a single server route where the runtime supports it. Replace four client-side useEffect calls with one server endpoint returning all four payloads in a parallel Promise.all. Saves three round trips and gives you one place to cache, rate-limit, and observe the dashboard load.
  7. Pin the Vercel project region to the Supabase region. One dropdown, single biggest p95 win on misconfigured projects.
  8. Move from direct Postgres connections to Supavisor transaction mode. Connection-string change only. Caps connection growth under Vercel concurrency.
  9. Add edge cache headers. Public list endpoints with Cache-Control: public, s-maxage=60, stale-while-revalidate=300 will absorb most of a Hacker News spike before it ever reaches Postgres.
  10. Re-run the same k6 script. This is the payoff: the before/after table you can put in front of investors.

"But our app is small, do we really need this?"

Two objections we hear, both worth answering directly.

"We don't have launch-spike traffic yet." Profile 2 in our load plan is 50 VUs steady-state for five minutes. With a 1-4 second think time and the endpoint mix above, that is enough to surface the class of failures that later shows up as "normal Tuesday" traffic. Our target for this dashboard class is p95 < 300ms, p99 < 800ms, and error rate < 1%. You don't need Hacker News to hit the wall.

"We'll fix it when we hit scale." This is the panic-fix path: compute upgrades that don't address the underlying problem, four-figure egress bills from a single bad launch, and an RLS schema that gets harder to fix the more data you have. The repair costs less in week one than in month six. Stop upgrading compute until the query plan is clean.

Why we trust this pattern

We run Grovs, our open-source attribution and deep-linking infrastructure, at 10M+ events per day. Grovs is not a Lovable + Vercel clone; it runs on high-volume Postgres-backed attribution infrastructure with Redis, Rails, native SDKs, and an EU-hosted cloud setup. That is exactly why the lesson transfers. The architecture for "ten users" and "ten million events" starts diverging the moment you decide whether indexes, pagination, rate limits, and cache boundaries exist.

If your Lovable export is live and untested, run a k6 script against it this week. The 100-line script in the appendix of any k6 tutorial is enough to surface the four failure modes above inside the first ninety seconds. If the numbers come back ugly, the fixes are usually closer to indexes, pagination, cache headers, and region pinning than a full rebuild.

If you'd rather we run the test and ship the fixes, our engineering team does this almost every week. Book a call. Move fast. Stay small. Build big, but only after the load test passes.