When an API route crawls, the culprit is rarely your JavaScript. It is a query. An unindexed lookup, an accidental N+1, or a Supabase call that waits on a cold connection. The trouble is that the query hides inside the route, and most tools only show you the route.
Why Is the Database Usually the Culprit?
A Prisma query that runs in 4ms on your machine can sit at 300ms in production, waiting on connection-pool limits or a query planner that gave up on your index. You will never reproduce it locally. You have to watch it where it happens, which is why it belongs in the wider indie hacker monitoring stack.
How Do You Find the Slow Query?
Your Supabase and Prisma calls run inside API routes, so per-route latency is your first map. When we profiled a Supabase-backed /api/orders, it sat at a 500ms P95 while every other endpoint stayed under 50ms. That is how you find the route hiding the slow query. The Node framework guide covers the one-line install that surfaces it. From there, run EXPLAIN ANALYZE on the suspect query to see whether it is doing a sequential scan instead of an index lookup, and check your pool size against real concurrency: a serverless function that fans out to fifty instances will drain a ten-connection pool in a heartbeat.
import { Statvisor } from "@statvisor/sdk";
const sv = new Statvisor({ apiKey: process.env.STATVISOR_API_KEY! });
app.use(sv.express());
// /api/orders now reports P95 across real trafficHow Do You Annotate a Slow Prisma Query?
Once you know the route, add a structured log line around the suspect query so the timing lands on the same timeline as the latency:
const start = Date.now();
const orders = await prisma.order.findMany({ where: { userId } });
sv.log("info", "orders.findMany", { ms: Date.now() - start, count: orders.length });Statvisor pairs per-route P95 with structured logs from the same key, so a slow Supabase call shows up as both a latency spike and the log line that explains it.
Fix the Usual Culprits
| Symptom | Likely cause | Fix |
|---|---|---|
| Fast locally, slow in production | Cold connection pool | Raise the pool ceiling or use a pooler |
| One route dominates P95 | N+1 read | Batch into one query or a Prisma include |
| Latency grows with row count | Missing index | Add the index EXPLAIN keeps suggesting |
| Same read on every request | No caching | Cache the value that never changes |
Read the Tail, Not the Mean
A database problem almost always lives in the P99, not the average, because pool exhaustion and lock contention hit a small fraction of requests hard rather than slowing every request a little. That is exactly how a route looks healthy on the mean and still generates support tickets. Watch the P99 per route, and when it climbs, reach for the log line you added around the query to see whether the time went to the database or to your own code. Fix the query, deploy, and confirm the P99 actually drops rather than assuming it did. For the full method of reading that tail, see P50 vs P95 vs P99: why averages lie, and to instrument the route in the first place, the Node framework guide has the one-liner.
Ready to monitor your API in production?
Statvisor gives you latency percentiles, error rates, and request volume for every route, in minutes, not days.
Get started free →