The Big "Server Waterfall Problem" with RSCs
Understanding server-side waterfalls with RSCs and client-side waterfalls we're familiar with and why server-side waterfalls are probably better.

The web has come a long way since the early days of Multi-Page Applications (MPAs). What started as simple server-rendered HTML has evolved through several architectural paradigms, each solving problems introduced by the previous approach. Today, React Server Components (RSCs) represent what might be the most significant leap forward yet.
Let me take you through this complete journey and show you why each step mattered.
In the beginning, there were Multi-Page Applications. Each user interaction resulted in a full page reload, with the server generating complete HTML documents.
Here's what a simple counter app looked like in those days:
app.get('/', async (c) => { const count = await db.getCount() const html = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>MPA Counter</title> </head> <body> <h1>Count: ${count}</h1> <form action="/update-count" method="POST"> <button type="submit" name="change" value="-1">Decrement</button> <button type="submit" name="change" value="1">Increment</button> </form> </body> </html> ` return c.html(html)})app.post('/update-count', async (c) => { const formData = await c.req.formData() const change = Number(formData.get('change')) await db.changeCount(change) return c.redirect('/')})
The Good:
The Problems:
The POST → Redirect → GET pattern was a common solution to prevent duplicate form submissions, but it still required full page reloads.
React introduced a paradigm shift. Instead of server-rendered HTML, we moved to client-side JavaScript that managed the entire application state.
Here's how our counter app evolved:
export function Counter() { const [state, dispatch] = useReducer(countReducer, { count: null, loading: true, error: null, }) useEffect(() => { async function fetchCount() { dispatch({ type: 'FETCH_START' }) try { const response = await fetch('/count') if (!response.ok) throw new Error('Failed to fetch count') const data = await response.json() dispatch({ type: 'FETCH_SUCCESS', payload: data.count }) } catch (err) { dispatch({ type: 'FETCH_ERROR', payload: err.message }) } } fetchCount() }, []) async function updateCount(change) { dispatch({ type: 'UPDATE_START' }) try { const response = await fetch('/update-count', { method: 'POST', body: JSON.stringify({ change }), headers: { 'Content-Type': 'application/json' }, }) if (!response.ok) throw new Error('Failed to update count') const { count } = await response.json() dispatch({ type: 'UPDATE_SUCCESS', payload: count }) } catch (err) { dispatch({ type: 'UPDATE_ERROR', payload: err.message }) } } if (state.loading && state.count === null) return <div>Loading...</div> if (state.error) return <div>Error: {state.error}</div> return ( <div> <h1>Count: {state.count}</h1> <div style={{ opacity: state.loading ? 0.6 : 1 }}> <button onClick={() => updateCount(-1)}>Decrement</button> <button onClick={() => updateCount(1)}>Increment</button> </div> </div> )}
The Good:
The Problems:
Notice how much more code we now need just to handle network requests! We have to manage loading states, error states, and manually wire up all the data fetching logic.
Frameworks like Remix emerged to solve these problems by bringing back progressive enhancement while maintaining the benefits of SPAs. They introduced concepts like loaders and actions to handle data fetching and mutations.
export async function loader() { const db = await import('../db.js') return { count: await db.getCount() }}export async function action({ request }) { const db = await import('../db.js') const formData = await request.formData() const change = Number(formData.get('change')) await db.changeCount(change) return { success: true }}export function Counter() { const data = useLoaderData() const fetcher = useFetcher() return ( <div> <fetcher.Form method="POST" action="/counter"> <h1>Count: {data.count}</h1> <div style={{ opacity: fetcher.isPending ? 0.6 : 1 }}> <button type="submit" name="change" value="-1"> Decrement </button> <button type="submit" name="change" value="1"> Increment </button> </div> </fetcher.Form> </div> )}
The Good:
The Problems:
This approach significantly reduced the network management code, but it introduced a new problem: the composition boundary was now the route, not the component.
React Server Components represent a fundamental shift back to component-based composition while eliminating the need for custom network management code entirely.
'use server'import * as db from '../db.js'import { PendingDiv } from './pending-form.js'export async function updateCount(formData) { try { const change = Number(formData.get('change')) await db.changeCount(change) return { status: 'success', message: 'Success!' } } catch (error) { return { status: 'error', message: error?.message || String(error) } }}export async function Counter() { const count = await db.getCount() return ( <div> <h1>Count: {count}</h1> <form action={updateCount}> <PendingDiv> <button type="submit" name="change" value={-1}> Decrement </button> <button type="submit" name="change" value={1}> Increment </button> </PendingDiv> </form> </div> )}
And the client component for pending state:
'use client'import { useFormStatus } from 'react-dom'export function PendingDiv(props) { const { pending } = useFormStatus() return <div style={{ opacity: pending ? 0.6 : 1 }} {...props} />}
The Good:
The Problems:
Each step in this evolution solved real problems while introducing new challenges that the next step would address:
The key insight is that React Server Components aren't just another framework feature—they're a fundamental rethinking of how we compose applications. By moving the composition boundary back to the component level and eliminating the need for custom network management code, RSCs give us the best of all previous approaches:
The most remarkable thing about React Server Components is how they make network management code disappear. In the early React days, we had to manually manage:
With RSCs, the framework handles all of this automatically. Server components can fetch data directly, and server actions handle mutations with built-in pending states and error handling.
This isn't just about reducing boilerplate—it's about eliminating entire categories of bugs and complexity that developers have been wrestling with for years.
React Server Components represent what might be the most significant architectural advancement in web development since React itself. They solve the fundamental tension between server-side rendering and client-side interactivity by making the distinction explicit and manageable.
As the ecosystem continues to mature around RSCs, we're seeing a new generation of applications that combine the best aspects of all previous approaches. The network management code that once dominated our applications is finally vanishing, replaced by a simpler, more powerful model.
The journey from MPAs to RSCs shows us that web development is constantly evolving, with each step building on the lessons learned from previous approaches. React Server Components aren't just the latest trend—they're the natural evolution of web architecture, bringing us closer to the ideal of simple, performant, and maintainable web applications.
This article is based on the "React and the Vanishing Network" workshop, which provides hands-on experience with each of these architectural approaches. You can explore the complete code examples and see the evolution in action. You can also watch a talk version of this workshop from my talk at React Paris 2025
Delivered straight to your inbox.
Understanding server-side waterfalls with RSCs and client-side waterfalls we're familiar with and why server-side waterfalls are probably better.
Master focus management in React with flushSync. Learn why batching matters, when to use flushSync, and how to create accessible, keyboard-friendly UIs.
How and why you should use CSS variables (custom properties) for theming instead of React context.
A basic introduction memoization and how React memoization features work.