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.