Your React App Is Fetching Data Like It's 2021 — The RSC Streaming Pattern That Actually Works

I talk to MERN devs all the time who've migrated to the Next.js App Router and still can't figure out why their pages feel slow. They're using React Server Components — technically. But they're doing it in a way that quietly cancels out most of the benefits. The fix isn't complicated. It just never gets explained properly.
So let's fix that right now.
The Old Mental Model Is Still Running Your Code
Here's the pattern that's burned into most React developers' muscle memory:
// The 2021 way — still everywhere in 2026
export default function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/stats').then(res => res.json()).then(setData);
}, []);
if (!data) return <Spinner />;
return <StatsView data={data} />;
}
You fire the component, it renders a spinner, it fetches, it re-renders. Fine. But now imagine three components doing this in a tree. Each one waits for its parent to render before it even starts fetching. That's a waterfall — and it's silently murdering your Largest Contentful Paint.
The thing is, when you move to the App Router, React Server Components give you a way out of this entirely. Not "slightly better" — architecturally different.
The Shift: Data Fetching Is Now a Server-Side Concern
With RSC, your server component is just an async function. That's it.
// The RSC way — runs on the server, ships zero JS
export default async function Dashboard() {
const stats = await db.collection('stats').findOne({ userId: 'me' });
return <StatsView data={stats} />;
}
No useEffect. No useState. No loading flicker. The component fetches its own data before it ever gets sent to the browser. Your MongoDB query runs on the server, the HTML streams down, and the client never has to wait on a fetch call at all.
If you're on a MERN stack, this is actually a big deal — your Express API layer becomes optional for these read-heavy data flows. The server component can talk directly to MongoDB (or hit your Express API, if you want to keep that separation). Either way, the browser is out of the data-fetching loop.
The rule of thumb for placement: push "use client" as far down the tree as possible — only to leaf-level components that actually need interactivity. Keep layouts, wrappers, and data-fetching components on the server.
Suspense Boundaries: The Part That Trips Everyone Up
Once you've got server components handling your data, you need Suspense to handle the streaming timing. Here's where most implementations go sideways.
Bad: one giant boundary
<Suspense fallback={<PageSpinner />}>
<Dashboard /> {/* wraps everything — user sees nothing until all data resolves */}
</Suspense>
Better: independent boundaries per data dependency
export default function DashboardPage() {
return (
<main>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</main>
);
}
Each section streams independently as its data resolves. StatsPanel might resolve in 40ms while RecentActivity takes 200ms — the user sees stats almost immediately instead of waiting for the slowest one.
One thing to watch: don't go overboard. Too many boundaries and you get what the community has started calling the "popcorn effect" — rapid skeleton-to-content transitions that feel janky and unpolished. A dashboard with 10+ independent Suspense boundaries can feel worse than a single skeleton. Match the granularity to how independently the sections actually load.
Also, make your skeletons dimension-matched. A skeleton that's a different height than the content causes layout shift (bad for CLS), and layout shift is one of those things that users notice even if they can't name it.
Partial Prerendering: The Edge + Streaming Combo
If you want to go further, Partial Prerendering (PPR) is the move. Available since Next.js 15 and stable in 16.x, it lets you serve a static shell from the edge cache instantly while streaming dynamic content behind it.
// next.config.js
const nextConfig = {
experimental: { ppr: true }
};
The static shell (header, navigation, layout structure) hits from a CDN edge node — we're talking ~40ms. Dynamic Suspense boundaries then stream in as they resolve. The result: users see something real almost immediately, then watch the page fill in, rather than staring at a white screen while the whole thing assembles.
A real-world case study using this pattern showed TTFB dropping from 450ms to 45ms, and LCP going from 1.2s to 380ms. That's not a small tweak — it changes how the page feels.
For parallel data fetching across sibling server components, don't await sequentially:
// Sequential — slow
const user = await getUser(id);
const orders = await getOrders(id);
// Parallel — fast
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
Sibling server components in different Suspense boundaries already run in parallel automatically. But inside a single component, you still need to manage it yourself.
The Nginx Detail That Kills Streaming Silently
This one is easy to miss and hard to debug. If your Next.js app sits behind Nginx (common in MERN deployments), check your proxy config:
# Without this, Nginx buffers the entire response before forwarding it
location / {
proxy_pass http://localhost:3000;
proxy_buffering off; # ← this line matters
}
With proxy_buffering on (the default), Nginx collects the entire streamed response before sending it to the client. Your beautiful streaming architecture gets silently turned into a blocking request. Users still see the spinner. You scratch your head wondering why RSC isn't helping.
Same thing can happen with other reverse proxies or CDN configurations — look for response buffering settings and turn them off for your Next.js origin.
What This Means for Your MERN Stack
If you're running a classic MERN setup with Express handling your API, RSC doesn't replace that — it adds a new layer you can choose to use. For read-heavy, server-rendered pages, you can skip the Express round-trip entirely and let server components hit MongoDB directly. For mutations, your Express API and client-side handlers stay exactly as they are.
The mental model isn't "replace Express." It's "move data fetching closer to the server, let HTML stream to the client, and stop treating the browser as the orchestrator of your data dependencies."
Once you internalize that shift, the useEffect waterfall starts to look like what it always was: a workaround for a constraint that no longer exists.
Wrap Up
React Server Components have been mainstream for a while now — but a lot of developers are using them without actually getting the performance benefit. The patterns that unlock the real gains are: async server components instead of useEffect fetching, fine-grained Suspense boundaries that stream independently, PPR for edge caching + streaming combined, and making sure your proxy config doesn't silently buffer everything back to a blocking response.
If your MERN app is still loading data on the client first, there's a real performance win sitting on the table. Go get it.





