Skip to main content
  1. Frontends/
  2. React Guides/

Hydration vs. Resumability: Can React 19 Close the Performance Gap?

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

It’s 2025. The “Uncanny Valley” of web performance is something we all know too well.

You load a page. It paints instantly. You see the button. You click the button. Nothing happens. Three seconds later, the UI finally jumps, and the event fires. That frustration is the cost of Hydration.

For years, the React ecosystem accepted this cost as the price of admission for a declarative, component-based UI. But with the rise of “Resumable” frameworks (like Qwik) challenging the status quo, the React team had to respond. And respond they did—not by abandoning hydration, but by fundamentally changing how it works via React Server Components (RSC) and Selective Hydration.

In this article, we aren’t just comparing buzzwords. We are going to look at the architectural differences, analyze the “waterfall” of interactivity, and write code to minimize hydration costs in a modern React 19 application.

The Prerequisites
#

Before we tear apart the rendering lifecycle, ensure you have your environment set up for modern React development. We will be using Next.js (App Router) as the implementation vehicle for these concepts, as it currently offers the most stable implementation of React 19’s architectural vision.

  • Node.js: v20.x or later (LTS recommended).
  • React: v19.0 (Stable).
  • Next.js: v15+ (App Router enabled).
  • Knowledge: Familiarity with SSR (Server-Side Rendering) and the basic concept of the Virtual DOM.

Hydration vs. Resumability: The Architectural Divide
#

To optimize React, you have to understand what it’s actually doing wrong (according to the “Resumability” camp).

The Hydration Approach (React’s Standard)
#

Hydration is essentially a replay strategy.

  1. Server: Renders the app to HTML string.
  2. Client: Downloads HTML (fast FCP).
  3. Client: Downloads the entire bundle of JavaScript for that page.
  4. Client: Executes the JS to rebuild the component tree in memory.
  5. Client: Attaches event listeners to the existing DOM nodes.

The bottleneck: You pay the “execution cost” twice. Once on the server to generate HTML, and once on the client to make it interactive.

The Resumability Approach (The Challenger)
#

Resumability is a “pause and play” strategy.

  1. Server: Renders the app and serializes the framework’s state/event closures into the HTML.
  2. Client: Downloads HTML.
  3. Client: Done.

There is no “startup” phase. Event listeners are delegated globally. When you click a button, a tiny chunk of JS is lazy-loaded specifically for that interaction.

Visualizing the Timeline
#

Here is how the main thread looks for both architectures. Notice the massive block of “Hydration Logic” in the traditional model.

sequenceDiagram autonumber participant U as User participant B as Browser participant S as Server note over B: Traditional Hydration (Standard React) U->>B: Requests Page B->>S: GET / S-->>B: Returns HTML + JSON Data B->>B: Paints HTML (FCP) B->>S: Request Bundle.js S-->>B: Returns JS B->>B: Parse & Execute JS (Main Thread Blocked) B->>B: Reconcile VDOM & Attach Events (Hydration) note right of B: Interactive here (TTI) U->>B: Clicks Button B->>B: Event Handler Runs note over B: Resumability / Optimized RSC U->>B: Requests Page B->>S: GET / S-->>B: Returns HTML + Serialized Closures B->>B: Paints HTML (FCP) note right of B: Interactive Immediately* U->>B: Clicks Button B->>S: Request Specific Chunk (Lazy) S-->>B: Returns Handler JS B->>B: Execute Handler

Note: React 19 with RSC moves closer to the bottom model by reducing the amount of JS sent, but it still performs a hydration phase for the interactive parts (Client Components).

Optimizing React: The “Island” Strategy
#

React’s answer to resumability isn’t to remove hydration entirely, but to make it so granular that the user doesn’t feel it. We achieve this by pushing everything to the server by default and only hydrating the “leaves” of our component tree.

Let’s look at a scenario: A dashboard with a heavy data chart and a simple “Dark Mode” toggle.

The Anti-Pattern: Root Hydration
#

In older versions of React (or bad usage of Next.js), we might wrap the whole page in a client context.

// src/app/dashboard/page.jsx
'use client'; // 🚨 The Sledgehammer approach

import { useState, useEffect } from 'react';
import { HeavyChart } from '@/components/HeavyChart';
import { Navbar } from '@/components/Navbar';

export default function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // Client-side fetching results in a waterfall
    fetch('/api/analytics').then(res => res.json()).then(setData);
  }, []);

  if (!data) return <div>Loading...</div>;

  return (
    <div>
      <Navbar /> 
      {/* 
         Even though Navbar is static, it's now part of the 
         hydration bundle because the parent is a Client Component.
      */}
      <HeavyChart data={data} />
    </div>
  );
}

Why this fails performance metrics:

  1. LCP (Largest Contentful Paint): Delayed because data fetching happens after JS loads.
  2. TBT (Total Blocking Time): The browser has to hydrate the Navbar, the layout, and the HeavyChart all at once.

The Solution: React Server Components & Streaming
#

We restructure this to leverage Selective Hydration. The goal: The server sends HTML, and React only hydrates the interactive bits.

1. The Server Component (The Skeleton)
#

This component never runs in the browser. It adds zero kilobytes to the bundle.

// src/app/dashboard/page.jsx
import { Suspense } from 'react';
import { Navbar } from '@/components/Navbar'; // Assumed Server Component
import { InteractiveChart } from '@/components/InteractiveChart';
import { SkeletonChart } from '@/components/Skeletons';

// Standard server-side data fetching
async function getAnalyticsData() {
  // Simulate DB call
  await new Promise(resolve => setTimeout(resolve, 1500)); 
  return [
    { month: 'Jan', value: 400 },
    { month: 'Feb', value: 300 },
    { month: 'Mar', value: 600 },
  ];
}

export default async function DashboardPage() {
  // Fetch starts immediately on request
  const data = await getAnalyticsData();

  return (
    <main className="min-h-screen bg-gray-50 p-8">
      {/* Navbar is rendered to HTML, no hydration cost */}
      <Navbar title="React 19 Dashboard" />
      
      <section className="mt-8">
        <h1 className="text-2xl font-bold text-gray-900 mb-4">
          Q1 Performance
        </h1>
        
        {/* 
          Suspense allows the rest of the page to hydrate 
          while this boundary streams in later.
        */}
        <Suspense fallback={<SkeletonChart />}>
          <InteractiveChart data={data} />
        </Suspense>
      </section>
    </main>
  );
}

2. The Client Component (The Hydration Target)
#

We isolate the InteractiveChart. Only this component (and its children) will add to the JavaScript bundle size.

// src/components/InteractiveChart.jsx
'use client'; // This directive marks the hydration boundary

import { useState } from 'react';
import { BarChart, Bar, XAxis, Tooltip, ResponsiveContainer } from 'recharts';

export function InteractiveChart({ data }) {
  const [activeIndex, setActiveIndex] = useState(null);

  return (
    <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
      <div className="h-64 w-full">
        <ResponsiveContainer width="100%" height="100%">
          <BarChart data={data} onMouseMove={(state) => {
            if (state.isTooltipActive) {
              setActiveIndex(state.activeTooltipIndex);
            } else {
              setActiveIndex(null);
            }
          }}>
            <XAxis dataKey="month" />
            <Tooltip 
              cursor={{ fill: 'transparent' }}
              contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
            />
            <Bar 
              dataKey="value" 
              fill="#3b82f6" 
              radius={[4, 4, 0, 0]}
            />
          </BarChart>
        </ResponsiveContainer>
      </div>
      <div className="mt-4 text-sm text-gray-500 text-center">
        {activeIndex !== null 
          ? `Analyzing ${data[activeIndex]?.month}...` 
          : 'Hover over bars for details'}
      </div>
    </div>
  );
}

Why this works
#

In this model, React streams the HTML for the Navbar and layout immediately. The browser parses it. There is zero hydration for those elements. The browser then waits for the JS chunk for InteractiveChart. Once that arrives, React hydrates only that specific div.

This drastically lowers the TBT (Total Blocking Time), mimicking the benefits of resumability without abandoning the React ecosystem.

Performance Showdown: React 19 vs. “True” Resumability
#

Is React’s selective hydration as good as Qwik’s resumability? Not exactly, but it’s getting close enough for 99% of use cases.

Feature React 19 (RSC + Streaming) Qwik (Resumability) The Verdict
Initial JS Load Low (Only Client Components) Near Zero (~1kb loader) Qwik wins on pure payload size.
Event Execution Requires hydration of component Lazy-loaded on interaction React is faster after hydration; Qwik is faster to interact.
State Serialization Passes props from Server to Client Serializes full application state React’s prop passing is simpler but less granular.
Network Waterfalls Mitigated via Server Components Eliminated via architecture Tie. Both solve this well.
Developer Experience High (Huge ecosystem) Moderate (New paradigms) React wins on maturity and library support.

Pro Tip: In 2025, the metric to watch is INP (Interaction to Next Paint). Google now punishes sites that paint pixels but stall on input. Selective Hydration is React’s primary weapon against poor INP scores.

Common Pitfalls in Modern React
#

Even with RSC, you can accidentally “de-opt” your application back into a monolithic hydration nightmare.

  1. Passing Non-Serializable Props: You cannot pass functions or classes from a Server Component to a Client Component.

    // ❌ Error: Functions cannot be passed to client components
    <Button onClick={() => console.log('Server log')} /> 
    
    // ✅ Solution: Pass Server Actions or handle logic in the client
    <Button action={myServerAction} />
  2. Importing Server Modules in Client Components: If your 'use client' file imports a utility that uses fs or database drivers, your build will fail (or worse, bloat the bundle if it polyfills). Keep your boundaries clean.

  3. The “Context” Trap: Wrapping your entire layout.jsx in a Context Provider (like ThemeProvider) forces the entire tree to be client-side rendering if that provider is not carefully implemented to just render children. Solution: Ensure your providers accept children and render them directly, so the server components passed as children remain server components.

Conclusion
#

Hydration is not dead, but “Monolithic Hydration” is.

React 19 has effectively conceded that the “Resumability” proponents were right about one thing: sending too much JavaScript is the root of all evil. By adopting Server Components and Streaming, React allows us to keep the component model we love while drastically reducing the cost of initialization.

While frameworks like Qwik offer a mathematically purer solution to the startup problem, React’s approach allows millions of existing applications to migrate incrementally.

Your Action Plan:

  1. Audit your Next.js/React app. Look for 'use client' at the top of page files. Move it down the tree.
  2. Use Suspense boundaries aggressively to break up hydration tasks.
  3. Monitor your INP scores in Google Search Console. If they are high, you are hydrating too much at once.

The gap between Hydration and Resumability is narrowing. It’s up to you to architect your app to cross it.


Further Reading
#