Scaling an MVP: what breaks first
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
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
2xxquickly, 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
| Symptom | Likely cause | First fix |
|---|---|---|
| Pages slow down as data grows | Missing indexes, N+1 queries | explain analyze, then add indexes and joins |
| Errors about too many connections under load | Serverless functions opening direct connections | Transaction pooler, prepared statements off |
| 504 errors on some actions | Long tasks inside requests | Background jobs and direct-to-storage uploads |
| Customers charged but not upgraded, or upgraded twice | Webhooks not idempotent or order-dependent | Store event IDs, queue the work |
| Users quietly drop off | Errors nobody sees | Error alerts, searchable logs, uptime checks |
| Hosting bill jumps | Functions doing work that could be cached or queued | Cache 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.
- PostgreSQL 18 docs, Constraints (foreign keys), checked 10 November 2026
- PostgreSQL 18 docs, Connections and authentication (max_connections), checked 10 November 2026
- Supabase docs, Row Level Security (performance), checked 10 November 2026
- Supabase docs, Connecting to Postgres, checked 10 November 2026
- Vercel docs, Functions limits, checked 10 November 2026
- Stripe docs, Receive Stripe events in your webhook endpoint, checked 10 November 2026
The launch checklist for founders, as a PDF
About 40 checks across legal, payments, domain and email, analytics, backups and support.
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