Slow with real data
The app is unusably slow with real data
Your app was quick in the demo because the demo had almost no data: twenty rows, one user, everything on one screen. Production has ten thousand rows, and every shortcut the AI took now has a cost that scales with the table. Nothing here is broken in the crashing sense. Each pattern below simply multiplies work by the number of rows, which is why the app got slower gradually and then suddenly. The useful part: this is the one failure you can put a number on, before and after, so you will know exactly what you bought.
Fixed £4,500 · 3 days · pass-or-refund · next slot w/c 3 August
What actually breaks
- One query per row in the listAI assistants love this shape: fetch a list, then loop over it and fetch each row's related records inside the loop. One query for the list plus one per row, the N+1 pattern. At twenty rows that is 21 queries and nobody notices. At five thousand it is 5,001 round trips, each paying network latency to the database. At one millisecond each that is five seconds before the page renders; with a database in another region, multiples of that. The fix is one query with a join or an include.
- Foreign keys with no index behind themPostgres does not create an index on the referencing column when you declare a foreign key. That is documented behaviour, not a bug. AI-generated migrations declare the relationship and stop there, so every lookup of orders by customer becomes a sequential scan of the whole orders table. At a few hundred rows a scan is instant; at hundreds of thousands, each scan takes real time, and a page that runs ten of them stacks the cost. The same goes for any column you filter or sort on: status, created_at, tenant.
- No pagination, so every visit fetches the whole tableThe generated list page selects every row and renders every row. It worked when the table was small. Now the payload grows linearly with your data, and serialisation, transfer and render time grow with it. Some stacks mask the problem: Supabase returns at most 1,000 rows by default, so the page stays fast but silently truncates, and users report missing records instead of a slow screen. Either way the fix is the same: the server returns one page of rows with a hard limit and a cursor for the rest.
- Totals computed by loading every rowThe dashboard shows a handful of totals: revenue, orders, active users. The generated code computes each one by fetching the entire table and summing in JavaScript. The database can answer COUNT and SUM with one aggregate query returning one row; instead every request drags the whole table across the network, adds it up, and throws it away. Time and memory scale with your history, not your traffic, and on serverless hosting the endpoint can hit the memory limit and fail outright. Push the arithmetic into SQL.
- Full-size photos in thumbnail slotsUsers upload photos straight from their phones, typically 2 to 8 MB each depending on handset and format, and the generated code renders the raw storage URL inside a 100 pixel card. A grid of thirty items at 3 MB each is 90 MB of transfer for one screen. No resizing on upload, no transform on delivery, and usually no width or height attributes, so the layout jumps while images land. Serve resized variants through your framework's image component or a transform service.
- Nothing is cached and nothing is warmNothing here is cached. Results that change once a day are recomputed on every single request, because nobody asked the AI for a cache and it did not volunteer one. On serverless hosting there is a second cost: after an idle period the next request starts a fresh instance, which loads your whole bundle and opens a new database connection before your code runs. Vercel's own guidance says startup times are correlated to function size. The first visitor after a quiet spell pays both costs at once.
The diagnosis is a number
Slow is the one symptom where the diagnosis is a number. First, reproduce it against production-scale data, not the demo dataset; a fix proven on twenty rows proves nothing. The browser's network waterfall then splits each slow page into server time, payload size and render time, which points at which of the six failures you have. On the server, query logging counts queries per request, so an N+1 shows up immediately, and pg_stat_statements ranks queries by total time. EXPLAIN ANALYZE on the worst offenders shows whether Postgres is scanning whole tables or using an index. Fixes land in order of measured impact, and every one is re-measured on the same endpoint with the same data. You get a before-and-after table, not an assurance.
What “done” looks like
We agree one of these in writing before you pay anything. If it passes, the sprint passed. If it doesn't, you get a full refund and keep the work.
- The slowest user journeys are named, with p95 response times recorded before and after on production-sized data.
- No endpoint runs a number of database queries that grows with the number of rows it displays.
- EXPLAIN on the hot queries shows index scans, and no foreign key or filtered column is left unindexed.
- Every list is paginated with a hard limit, and no page ships full-size originals where a thumbnail belongs.
We agree the acceptance test before any work starts. If it doesn't pass within three working days, you get a full refund and keep every change I've committed. The risk is mine, not yours.