Last winter we migrated a SaaS dashboard from the Next.js Pages Router to the App Router and React Server Components. The migration itself was surprisingly smooth — two engineers, nine days, no drama. We deployed on a Friday evening, had a beer, and thought we were done. Monday corrected us. These are the three things no tutorial or conference demo tells you — because in a demo with one fetch they never show up.
We're writing this as a note from practice, not a how-to. We still use Server Components in production and like them — they're at the core of how we build web applications today. We just learned three things the hard way along the way.
Thing #1: the waterfalls you can't see in the component tree
Every async server component waits for its own data. That's lovely until you nest them. Our dashboard had a page that rendered a layout, which held a panel, which held a widget — and each level fetched its own data. In the demo that was three quick fetches. In production it was a sequential waterfall: the layout waited for its query, only then did the panel start rendering, which waited for its own, only then the widget. Time-to-first-byte jumped from 200 ms to 1.4 seconds and nobody understood why — in dev, against a local DB, it was invisible.
The fix was boring: fetch data that doesn't depend on each other in parallel with `Promise.all` as high up the tree as possible, and wrap the slow parts in `Suspense` so the page starts streaming before the slow thing arrives. After the change TTFB dropped back under 300 ms. The lesson: nesting async components is nesting latencies, and you won't see it in dev.
Thing #2: the caching is aggressive and invisible
This one cost us the most. The App Router caches `fetch()` calls and whole routes by default. The demo celebrates this as a feature — and it is one. Except our dashboard showed data that changed every minute, and we were serving users a state from hours ago. No error in the log, no alert. Just a client writing in: 'why am I looking at yesterday's numbers here?'
The default is 'cache until I tell you otherwise', not 'fresh until you ask for a cache'. We had to go through every fetch and decide deliberately: this is `no-store`, this gets `revalidate: 60`, this can live for an hour. Only once we set caching on purpose did the dashboard behave predictably. Our rule since: for any dynamic data fetch, the cache strategy gets written on the first line — not when the client complains.
Thing #3: the 'use client' boundary leaks upward
When you mark a component `"use client"`, its entire subtree becomes client. You know that while you're working on leaves. You forget it when you push `"use client"` higher just to use one `useState`. We did it on a component near the top of a page for a charting library that required it — and quietly turned half of our 'server' components into client ones. The page's JS bundle grew by roughly 40% and we couldn't work out where the server-only parts had gone.
The fix isn't technical, it's disciplinary: `"use client"` belongs as low in the tree as possible — on a leaf, not a branch. We wrapped the charting library in a small client component and left the rest of the page on the server. The bundle fell back. This is exactly the pattern we describe with Redis — you add something 'just temporarily, high up' and a month later it carries a cost nobody decided on out loud.
Server Components aren't hard. What's hard is that the three things that bite you never show up in a one-fetch demo. They show up on Friday evening, after you deploy.
What we wrote into our rules afterwards
- Fetch independent data in parallel and as high as possible; put the slow parts in Suspense. Nested awaits are nested latencies.
- Write the cache strategy on purpose at every fetch. The default is cache — good for a blog, bad for a dashboard.
- Put `use client` on a leaf, not a branch. Every move upward adds the whole subtree to the bundle.
None of this means the App Router is bad. For most of the web applications we build today it's the default — the same way it was for Helply. We just learned that production tests precisely the three places a demo skips. If you're migrating a larger app to Server Components and want to avoid our Monday, get in touch — we're happy to look at your data flow before you deploy.