Guides / Reliability

It worked in the demo. Then twenty people logged in.

Demos lie: one user, a warm cache, and a table with nine rows. Production is concurrency, cold paths, and data that grows. AI-generated code optimises for looking correct, not for being efficient, and the gap between those two shows up as the app that dies at its own launch party. The fixes are cheaper than the fear.

The five failure modes, and their fixes.

1. N+1 queries

The app fetches a list, then runs one more query per item: ten orders become eleven round trips, a thousand become a thousand and one. AI tools generate this pattern constantly because each piece looks correct alone. The tell is a page that gets slower in proportion to how much data it shows. The fix is asking for the shape you need in one query; with supabase-js, nested selects do exactly that:

// N+1: one query for orders, then one per order for its items
const { data: orders } = await supabase.from("orders").select("*");
for (const o of orders) {
  const { data } = await supabase.from("order_items").select("*").eq("order_id", o.id);
}

// One round trip: let PostgREST join it
const { data: orders } = await supabase
  .from("orders")
  .select("*, order_items(*)");

2. Missing indexes

Small tables forgive everything; that is why the demo was fast. Once tables grow, every filter and join on an unindexed column is a full scan. Find the slow query, prefix it with explain analyze in the SQL editor, and if you see Seq Scan on a large table where you expected an index, add one:

create index idx_orders_user_id on public.orders (user_id);
create index idx_messages_thread_created on public.messages (thread_id, created_at desc);

Index your foreign keys and your common filter columns. This one change is the most frequent single-line performance rescue we perform.

3. Connection exhaustion

Postgres holds a limited number of connections, and serverless functions can each open their own. Under a burst, the pool empties and everything fails at once with connection errors, which reads like a crash but is really arithmetic. The fix on Supabase is using the pooled connection string (the pooler runs on port 6543) for anything serverless, and keeping direct connections for long-lived servers only.

4. Nothing is rate limited

One user with a stuck retry loop, or one bot that found your signup form, can consume everything the other four fixes bought. Public endpoints that send email, call paid APIs, or write to the database need per-user and per-IP limits; the proxy pattern shows where they slot in.

5. Zero caching

Vibe-coded apps re-fetch identical data on every render: the category list, the public catalogue, the settings blob. Start embarrassingly simple: HTTP cache headers on public GETs, a CDN in front of static assets, and letting your data library (React Query and friends) hold results for a sane staleTime instead of refetching on every focus.

Load test before launch. Ten minutes, free tools.

You do not need a performance team; you need ten minutes of honesty before strangers arrive. With k6, this script pushes twenty concurrent users through your busiest endpoint for a minute:

import http from "k6/http";
import { check, sleep } from "k6";

export const options = { vus: 20, duration: "60s" };

export default function () {
  const res = http.get("https://yourapp.example/api/orders");
  check(res, { "status 200": (r) => r.status === 200 });
  sleep(1);
}

Watch two numbers: the p95 response time, which is what your slowest-fifth of users feel, and the error rate, which should be zero at this scale. Then, carefully and against a non-production environment if you have one, exercise the busiest write path too; reads rarely exhaust connections, writes do.

Capacity is a product decision Estimate your realistic peak: a hundred concurrent users is a strong launch for most B2B products in this market. At that scale you do not need microservices or a rewrite. You need indexes, a pooler, rate limits, and caching, which is exactly the list above, and all of it is hours of work, not weeks.

Questions

Questions founders ask.

How many users can a Supabase or Firebase app actually handle?

Further than most founders assume. The managed platforms themselves scale well past early-stage needs; the ceiling in practice is almost always the application's own queries: N+1 patterns, missing indexes, and unpooled connections. Fix those and the same infrastructure that struggled at twenty users runs comfortably at thousands.

What is an N+1 query in plain terms?

Fetching a list, then making one additional query for every item in it: one query becomes N+1. It feels fast with ten rows and collapses with a thousand. The fix is asking the database for the complete shape in one query, via joins or nested selects, which databases are extremely good at.

Do I need to move off serverless to handle load?

Rarely. Serverless plus Postgres has one sharp edge, connection exhaustion, and the pooled connection string blunts it. Beyond that, serverless scales the compute for you. The workloads that genuinely outgrow it, long-running jobs and websockets-heavy systems, announce themselves clearly before they force a move.

When do I actually need to rearchitect?

When the fixes stop being local: the data model fights every feature, background work has no home, or one table serves five incompatible access patterns. That is an architecture conversation, not an emergency, and it is far rarer than a missing index wearing an architecture costume. Measure first; explain analyze is free.

Before launch day

Find the bottleneck before your users do.

A production-readiness review includes the load story: queries, indexes, pooling, limits, and a load test against your real flows, with the fixes ranked by what fails first.