Skip to content
BlackFig

Scaling an MVP: what breaks first

AntPublished Updated 5 min read

Quick answer

In most MVPs the database breaks first: missing indexes and too many connections from serverless functions. Next come long tasks that time out inside web requests, payment webhooks that run twice or out of order, and errors nobody sees. Fix those in that order before you think about bigger servers.

On this page
  1. The database goes first
  2. Then the connections run out
  3. Slow work inside requests
  4. Payments get messy
  5. Errors nobody sees
  6. What breaks, at a glance
  7. What doesn't break as early as you think
  8. Next step

The question founders ask is "will it scale?" The more useful question is "what will break first?" For a typical MVP on Next.js, Postgres and Stripe, hosted on something like Vercel and Supabase, the answer is fairly predictable. It's almost never raw server power. It's the database, then work that doesn't belong in a web request, then payments, then the errors nobody is watching.

Here's the order I check things in, with the fix for each.

The database goes first

AI-built and fast-built apps nearly always ship without the indexes they need. With 50 rows it doesn't matter. With 50,000, a page that filtered a table instantly starts taking seconds, because Postgres is reading every row.

Foreign keys are the classic gap. The PostgreSQL docs say it plainly: declaring a foreign key does not automatically create an index on the referencing columns. So every user_id, owner_id and project_id column your app filters on may be unindexed.

-- Find the slow query first, then index what it filters on.
explain analyze
select * from bookings where host_id = '...' order by starts_at desc limit 20;
 
-- An index that matches the filter and the sort.
create index bookings_host_id_starts_at_idx on bookings (host_id, starts_at desc);

If you use Supabase, Row Level Security adds its own version of this. Supabase's docs recommend an index on every column your policies filter on, and wrapping helper functions like auth.uid() in a select so Postgres can cache the result instead of calling it for each row:

create index bookings_user_id_idx on bookings using btree (user_id);
 
create policy "Users read their own bookings"
on bookings for select
to authenticated
using ( (select auth.uid()) = user_id );

The second database problem is N+1 queries: a list page that runs one query for the list and then one more per row. Generated code does this a lot, especially with ORMs. Look for queries inside loops and replace them with a join or a single in query.

Then the connections run out

Serverless hosting changes how an app talks to its database. Each function instance can open its own connections, and under load you can have many instances at once. Vercel's docs say functions auto-scale up to 30,000 concurrent executions on Hobby and Pro. Postgres, meanwhile, defaults to a max_connections of typically 100.

The symptom is an app that works fine in testing and then throws connection errors during a busy hour.

The fix is a connection pooler. On Supabase, the docs say to use the transaction mode pooler for serverless and edge functions, on port 6543, and keep the direct connection on port 5432 for migrations and long-running servers. One catch: the shared pooler doesn't support prepared statements in transaction mode, so you turn them off in your database client.

// lib/db.ts, using postgres.js
import postgres from "postgres";
 
// DATABASE_URL points at the transaction pooler (port 6543).
export const sql = postgres(process.env.DATABASE_URL!, {
  prepare: false, // transaction mode doesn't support prepared statements
});

Slow work inside requests

The next thing to break is any task that runs inside a web request but takes a while: generating PDFs, calling an AI model, sending a batch of emails, importing a CSV.

On Vercel, a function that runs past its limit returns a 504 FUNCTION_INVOCATION_TIMEOUT. The default with Fluid compute is 300 seconds on every plan, with up to 800 seconds on Pro and Enterprise. Request and response bodies are capped at 4.5 MB, which catches file uploads routed through a function.

Long before you hit those limits, users will have given up. The fix is to answer quickly and do the work in the background: write a job row, return, and let a worker or scheduled function pick it up. Large uploads should go straight from the browser to storage with a signed URL rather than through your function.

Payments get messy

Payments are where scaling problems cost money. Stripe's webhook docs set out the behaviour you have to design for:

  • Stripe retries failed deliveries for up to three days in live mode.
  • Your endpoint may receive the same event more than once.
  • Events are not guaranteed to arrive in the order they were created.
  • You should return a 2xx quickly, before any complex logic, and Stripe recommends an asynchronous queue to handle spikes such as the start of the month when subscriptions renew.

An MVP webhook handler that grants access, sends an email and updates a row inline will eventually double-send, time out, or process invoice.paid before the subscription exists. This shape avoids all three. It expects a Snapshot event destination, because new destinations in Stripe Workbench default to the Thin payload format, which constructEvent doesn't parse:

// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: Request) {
  const body = await req.text(); // raw body, needed for the signature check
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
 
  // Record the event ID first. A duplicate delivery inserts nothing and stops here.
  const inserted = await sql`
    insert into stripe_events (id, type, payload)
    values (${event.id}, ${event.type}, ${JSON.stringify(event)}::jsonb)
    on conflict (id) do nothing
    returning id`;
  if (inserted.length === 0) return new Response("Already received", { status: 200 });
 
  // A worker processes unhandled rows, fetching fresh objects from Stripe,
  // so arrival order doesn't matter.
  return new Response("ok", { status: 200 });
}

My guide to adding Stripe to an AI-built app goes through the rest of the payment set-up.

Errors nobody sees

The quietest failure is the one nobody reports. Most users who hit an error don't email you. They leave.

An MVP needs three things before it gets real traffic: error tracking that alerts you, logs you can search by user or request, and an uptime check on the pages that matter, such as sign-up and checkout. Without them, every other item in this article is guesswork.

What breaks, at a glance

SymptomLikely causeFirst fix
Pages slow down as data growsMissing indexes, N+1 queriesexplain analyze, then add indexes and joins
Errors about too many connections under loadServerless functions opening direct connectionsTransaction pooler, prepared statements off
504 errors on some actionsLong tasks inside requestsBackground jobs and direct-to-storage uploads
Customers charged but not upgraded, or upgraded twiceWebhooks not idempotent or order-dependentStore event IDs, queue the work
Users quietly drop offErrors nobody seesError alerts, searchable logs, uptime checks
Hosting bill jumpsFunctions doing work that could be cached or queuedCache read-heavy pages, move work off requests

What doesn't break as early as you think

Founders often worry about things that rarely bite in the first year: picking the "wrong" framework, needing microservices, multi-region databases. A single Postgres database, properly indexed, behind a pooler, carries a lot of users. Keep the architecture boring until a measurement tells you otherwise.

The exception is anything you can't change later without pain: who owns the repository and accounts, and how data is modelled. Those are worth getting right early. My guide to moving from no-code to code covers the data model side, and why AI-built apps get slow goes deeper on performance.

Next step

If your MVP is getting traffic and wobbling, start with the database and work down this list. For the bigger picture of going live, read my prototype to product guide. If you'd rather have someone else do it, the free health check is a quick first look, and my Rescue Sprint puts these fixes in place at a fixed price after a technical assessment (from £750). Book a call to talk about what's breaking.

The launch checklist for founders, as a PDF

About 40 checks across legal, payments, domain and email, analytics, backups and support.

You get the PDF now and two short follow-ups over the next week. Reply “stop” and the rest never arrive.

Questions founders ask

Do I need to rebuild my MVP to scale it?

Rarely at first. Most early scaling problems are a missing index, a connection setting, or slow work done inside a request. Those are fixes, not rewrites.

How many database connections can Postgres handle?

PostgreSQL's default max_connections is typically 100. Serverless functions can open far more than that under load, which is why a connection pooler matters.

Why do my Stripe webhooks run twice?

Stripe can deliver the same event more than once and retries failed deliveries for up to three days in live mode. Record each event ID and skip ones you've already processed.

When should I move to bigger servers?

After you've measured. If the slow part is a query without an index, a bigger database hides the problem for a while and costs more every month.

Ant

Product designer who builds MVPs, based in Nottingham. 10+ years designing digital products, and the only person at BlackFig. More about me