Performance · AI-built apps

How to scale a vibe-coded app before it breaks

Most vibe-coded apps don't have a scale problem. They have a "nobody added an index" problem, and the fix is cheap if you do it in the right order. Here's the exact order, with the SQL and code we actually run.

Does my app actually have a scale problem?

Almost certainly not, in the way you probably think. The overwhelming majority of "our app can't handle load" reports trace back to a missing index, an N+1 query, or a page loading every row with no limit, not a fundamental architecture ceiling.

That's genuinely good news. A missing index is a five-minute fix. An N+1 query is an afternoon. None of it requires switching frameworks, moving off your database, or hiring a distributed-systems team. This guide walks through the fixes in the order they actually pay off, cheapest and highest-impact first, so you fix the real bottleneck before you touch anything that isn't one.

There's a reason this pattern is so consistent in AI-built apps specifically. An AI coding agent optimizes for a feature that works against the sample of data it was tested with, usually a handful of rows in a local dev database. Every query returns instantly at that scale whether or not it's indexed, whether or not it's N+1, whether or not pagination exists at all. The gap between "works in the demo" and "works with ten thousand real rows" doesn't show up until it's someone's actual production traffic hitting it, which is exactly why this list exists as a pre-emptive pass, not just a fire drill.

STEP 01

Measure first, and look at p95, not average

You cannot fix what you haven't measured, and the average load time actively lies to you. If 95% of requests take 100ms and 5% take 8 seconds, the average reports a reassuring 450ms while one in twenty real users is having a miserable experience.

In Postgres, enable pg_stat_statements and pull your slowest queries directly, ranked by actual time spent, not guesswork:

select query, calls, mean_exec_time, max_exec_time
from pg_stat_statements
order by mean_exec_time desc
limit 20;

At the application layer, log per-route duration and compute p50/p95/p99, or use your host's built-in tracing (Vercel Analytics, Sentry Performance) if it's already there.

STEP 02

Fix N+1 queries

An N+1 query happens when code fetches a list, then loops over it making one more database call per item. Ten orders becomes eleven queries, ten thousand orders becomes ten thousand and one, and the app that felt instant in testing crawls the moment real data shows up.

The pattern, almost always written this way by an AI moving fast:

const orders = await db.order.findMany();
for (const order of orders) {
  order.customer = await db.customer.findUnique({ where: { id: order.customerId } });
}

The fix, one query instead of N+1, using the ORM's built-in relation loading:

const orders = await db.order.findMany({ include: { customer: true } });
STEP 03

Add the indexes your queries are actually asking for

Run EXPLAIN ANALYZE on your slowest query from step one:

explain analyze
select * from orders
where customer_id = 123
order by created_at desc
limit 20;

If the output includes Seq Scan on orders, Postgres is reading the entire table row by row to find matches, no index is helping it. The fix is a composite index with the equality column first and the sort column second, matching the query's own where and order by:

create index idx_orders_customer_created
  on orders (customer_id, created_at desc);

Re-run the same EXPLAIN ANALYZE and you should see Index Scan in place of Seq Scan, with a dramatically lower "actual time" in the output. One caution: every index speeds up reads but slows down every insert, update, and delete on that table, and takes real disk space. Index the columns your queries actually filter and sort on, not every column that seems important.

STEP 04

Fix pagination before it fixes your uptime

A query with no limit at all is a ticking clock:

select * from posts order by created_at desc; -- fine with 200 rows, fatal with 2 million

Offset pagination is the common first fix, and it degrades as pages get deeper, because the database still has to scan and discard every earlier row:

select * from posts order by created_at desc limit 20 offset 4000; -- gets slower every page

Keyset (cursor-based) pagination stays fast at any depth, because it filters directly to a starting point instead of counting past rows:

select * from posts
where created_at < $1  -- the created_at of the last row on the previous page
order by created_at desc
limit 20;
STEP 05

Add a caching layer for what changes rarely

Cache data that's read often and written rarely: product catalogs, configuration, public profile pages. A simple cache-aside pattern with Redis:

async function getProduct(id) {
  const cached = await redis.get(`product:${id}`);
  if (cached) return JSON.parse(cached);
  const product = await db.product.findUnique({ where: { id } });
  await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300);
  return product;
}

// invalidate on write, so stale data doesn't outlive the update
async function updateProduct(id, data) {
  await db.product.update({ where: { id }, data });
  await redis.del(`product:${id}`);
}

Beyond application caching: set Cache-Control headers on API responses that don't vary per user, and put static assets (images, JS, CSS) behind a CDN, most hosts (Vercel, Cloudflare) do this by default, just confirm it isn't disabled.

STEP 06

Fix connection pooling before it exhausts your database

This is the classic serverless failure: every function invocation opens a fresh Postgres connection, Postgres has a hard connection ceiling (often around 100), and under real load you exhaust it, every request starts failing with "too many connections" or hanging until one frees up.

The fix is routing through a connection pooler instead of connecting directly:

# direct connection - fine for a long-running server process
DATABASE_URL=postgres://user:pass@host:5432/db

# pooled connection - required for serverless / edge functions
DATABASE_URL=postgres://user:pass@host:6543/db?pgbouncer=true

Supabase, Neon, and most managed Postgres providers ship a pooler (often PgBouncer) on a separate port, in transaction mode, made specifically for this. Use it for anything serverless.

STEP 07

Move slow work off the request path

The request path must stay fast. Anything that isn't required to answer the request right now, sending an email, generating a PDF, calling a third-party API with unpredictable latency, belongs in a background job, not inline.

// slow: the response waits on however long the email provider takes
await sendWelcomeEmail(user);
res.json({ ok: true });

// fast: hand off the work and respond immediately
await queue.enqueue('send-welcome-email', { userId: user.id });
res.json({ ok: true });

A Postgres-backed job table with a worker loop works fine to start; managed options like Inngest, Trigger.dev, or BullMQ with Redis take over once volume grows.

STEP 08

Stop over-fetching

Returning every column of every row when the UI only renders three fields sends unnecessary weight over the wire on every request, and it's especially painful on mobile connections. Select only what's actually used:

const users = await db.user.findMany({
  select: { id: true, name: true, avatarUrl: true } // not select: '*'
});
STEP 09

Consider a read replica, but only if you still need one

A read replica spreads read traffic across a second database instance, at the cost of replication lag (a replica can serve slightly stale data) and real, ongoing operational complexity. Add one only after indexes are fixed, N+1 queries are gone, and a caching layer is in place, and your primary is still maxed on CPU or connections from legitimate read traffic.

In practice, almost no vibe-coded app reaches that point. A single well-indexed Postgres instance comfortably serves thousands of requests per second for a typical CRUD workload. Most requests for "we need a read replica" are, on inspection, actually a request for the index from step three.

If you do add one, be honest about the tradeoff you're taking on: a replica can lag behind the primary by anywhere from milliseconds to seconds under load, so any read that must reflect a write the same user just made (their own order, right after placing it) should still hit the primary. Routing that decision correctly, per query, is the real cost of a read replica, not the infrastructure bill.

STEP 10

Load test before you believe any of it

Every fix above is a hypothesis until you measure it under load. Use a tool like k6 or autocannon against a staging environment with production-like data volume:

autocannon -c 50 -d 30 https://staging.yourapp.com/api/orders

Record p95 before each fix and again after. If a fix doesn't move the number, it wasn't the bottleneck, go back to step one and measure again rather than guessing at the next thing to change.

What should I not do to scale?

Three moves that founders reach for out of anxiety, not evidence, each of which trades a problem you don't have yet for one you'll have permanently.

  • Don't shard the database before you've indexed it and cached it. Sharding solves a scale of problem 99% of apps never reach, and multiplies operational complexity permanently, for every query, every migration, every backup, forever.

  • Don't split into microservices to "scale." A single well-indexed monolith handles far more load than most apps ever see. Microservices trade a performance problem you don't have for a distributed-systems problem, network calls, partial failures, deployment coordination, you will have forever.

  • Don't rewrite in a faster language. Your bottleneck is almost never the runtime, it's an unindexed query or an N+1 loop. Rewriting in Rust or Go while keeping the same query pattern just buys a faster way to hit the same wall, at the full cost of a rewrite.

The honest order of operations

Measure, fix N+1s, add indexes, fix pagination, cache, pool connections, background the slow work, trim payloads, and only then ask about replicas or bigger architecture. In that order, most apps never need step nine at all.

Free, no course attached

The rescue playbook.

Real prompts, security rules, and teardowns of broken AI-built apps, sent when we publish something worth your inbox. No fluff, no drip sequence.

One or two emails a month. Unsubscribe anytime.

Not sure which of these is actually your bottleneck?

A senior engineer profiles your real queries and endpoints, finds the actual cause, and fixes the data model and infrastructure so it holds under real users, not synthetic guesses.

Questions

Common questions about scaling an AI-built app.

Does my vibe-coded app actually have a scaling problem?

Almost certainly not, in the way most people mean it. The overwhelming majority of slow AI-built apps are slow because of a missing database index, an N+1 query loop, or unbounded pagination, all of which are fixable in hours, not a rewrite or a move to a different architecture.

Why should I look at p95 instead of average load time?

Averages hide the experience of your worst-served users. If 95% of requests take 100ms and 5% take 8 seconds, the average might report a reassuring 450ms while one in twenty real users is having a terrible experience. p95 (and p99 for critical paths) shows you what your unluckiest real users actually feel.

When do I actually need a database read replica?

Only after you have already added the correct indexes, eliminated N+1 queries, and added a caching layer, and your primary database is still maxed on CPU or connections from legitimate, necessary read traffic. A single well-indexed Postgres instance handles far more load than most apps ever generate; most requests for a read replica are actually requests for an index.

Should I rewrite my app in a faster language to scale it?

No, in almost every real case. The bottleneck in a slow vibe-coded app is virtually never the language runtime, it's an unindexed query, an N+1 loop, or a missing cache. Rewriting in a faster language while keeping the same query pattern just gets you a faster way to hit the same wall, at the cost of a full rewrite.

How do I load test my app safely?

Run a tool like k6 or autocannon against a staging environment that mirrors production data volume, starting at a low concurrency and increasing gradually while watching your database CPU and connection count. Never load test production directly without warning your team and having a way to stop it immediately.