TL;DR: Server-rendered sites usually lose speed and money in the data layer, not the frontend: list queries fetching full article bodies to render titles, uncached pages hitting the database on every visit, and the same data fetched three times per render. Select only the fields you render, cache the rendered page, deduplicate per request, and read your database dashboard, because Lighthouse will never show you any of this.
Performance advice online is overwhelmingly frontend advice. Bundle size, image formats, hydration strategies, font loading. All real, all worth doing. But on server-rendered sites, the pattern I keep finding when something is actually slow (or actually expensive) is different: the data layer is doing five times the work the page needs, and nobody is looking at it because Lighthouse doesn't measure your database.
The listing page that downloads a library
The classic case. A blog index shows twenty cards: title, excerpt, date, thumbnail. The query behind it is findMany() on the posts table with no field selection. Every request pulls twenty full article bodies, in two languages if you're bilingual, plus every JSON column hanging off the model, to render twenty titles.
On localhost this is invisible. Milliseconds. In production it shows up in three places: response time under load, database egress on your bill, and connection pressure when traffic spikes. Serverless Postgres providers charge for data transferred out, and I've watched a listing query account for the majority of a site's egress while contributing nothing the page displayed.
The fix is unglamorous: select the fields you render. In an ORM that means an explicit select. Sometimes it means dropping to raw SQL for the hot listing queries, because the ORM's convenient shape is exactly the expensive shape. A query that returns titles and excerpts is a rounding error. A query that returns bodies is a document dump.
Cache the page, not the query
Once the queries are lean, the next question is how often they run. For content sites the honest answer is that most pages change rarely and it's fine for readers to see slightly stale content. This is what incremental static regeneration (or plain HTTP caching, or a CDN with a sensible TTL) is for: render the page, serve the rendered copy, refresh it on an interval.
An hourly revalidation on a blog means your database sees a handful of queries per page per day regardless of traffic. That's the difference between a database bill that scales with visitors and one that doesn't.
Two things bite people here. First, caches layer, and they don't all invalidate together. Your framework's data cache, the CDN, and the browser each have opinions. When you change an environment variable or repoint a database, purge deliberately; platform data caches surviving a redeploy is a genuinely confusing failure the first time you hit it. Second, ISR shifts when code runs, which changes what bugs look like. HTML generated at build time and HTML generated at revalidation time can differ in small structural ways, and anything downstream that compares or fingerprints your pages needs to treat both as normal.
Fetch once, share the result
Server components and template partials make it easy for three parts of a page to independently ask for the same data. Header wants the site settings, footer wants them too, the page body as well: three queries where one would do. Deduplicate at the request level. Most frameworks give you a cheap way to memoize a fetch for the duration of one render; use it, and hoist shared data to the layout.
The related smell is fetching in a loop. Twenty cards each triggering their own query is twenty round trips to a database that could answer it in one. Waterfalls that would be obvious in a network tab hide easily in server code.
Measure the layer nobody instruments
The reason database waste survives so long is that nothing points at it. Frontend performance has an entire industry of tooling. For the data layer you have to look yourself, and the looking is not hard:
Log query counts per route in development. If a page runs thirty queries, you'll only know because you counted. Check your database provider's dashboard for rows read and data transferred, then map the top consumers back to routes. And when a number looks wrong, it usually is. My rule of thumb: a content page should cost roughly the size of its HTML in database transfer. If it's costing ten times that, something is fetching bodies to render titles.
Frontend work makes a site feel fast. Data-layer work makes it cheap and keeps it fast when someone actually visits. Do both, but if the site is server-rendered and something's off, look at the queries first. That's where the waste hides, precisely because no score ever drops when it's there.
Key takeaways
- Listing pages must select the fields they render; a query returning bodies to show titles is a document dump.
- Cache the rendered page (ISR, CDN, HTTP caching) so database load stops scaling with traffic.
- Caches layer and do not invalidate together; purge deliberately after env or database changes.
- Deduplicate per-request fetches and hoist shared data to the layout; watch for query-per-card loops.
- Rule of thumb: a content page should cost about the size of its HTML in database transfer. Ten times that means something is fetching bodies for titles.
