Reducing Time to Interactive: Stream Bundles and Progressive Hydration | SoniNow Blog

Limited TimeLearn More

performancehydrationstreamingssrjavascript

Reducing Time to Interactive: Stream Bundles and Progressive Hydration

Published

2026-06-23

Read Time

5 mins

Reducing Time to Interactive: Stream Bundles and Progressive Hydration

Time to Interactive (TTI) measures when a page becomes reliably responsive to user input. Server-side rendering improves First Contentful Paint, but if the JavaScript bundle then blocks the main thread for seconds, the user sees a page they can't interact with. The solution lies in smarter delivery and selective hydration.

The Hydration Problem

Traditional SSR sends fully rendered HTML plus the entire JavaScript bundle. The browser must download, parse, and execute the bundle to attach event handlers—a process called hydration. On a React page with 500 KB of JS, that's 1–3 seconds of main thread blocking after the HTML appears.

The user sees the page but can't click anything. They don't know why. They leave.

// Traditional approach: hydrate everything at once
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document.getElementById('root'), <App />);

This is the root cause of poor TTI on SSR sites. The page looks ready but isn't.

Streamed HTML: Content at the Speed of the Server

Streaming SSR sends HTML as it's generated, so the browser can start rendering before the server finishes. Frameworks like Next.js (App Router) and Remix support streaming natively:

// Streaming SSR with Suspense boundaries
export default function Page() {
  return (
    <div>
      <Header />
      <Suspense fallback={<ArticleSkeleton />}>
        <ArticleContent /> {/* Streams when data is ready */}
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection /> {/* Streams later */}
      </Suspense>
    </div>
  );
}

The browser renders the header immediately while the article content streams in. Users see meaningful content sooner, even though the full page isn't interactive yet.

Progressive Hydration: Hydrate in Order

Instead of hydrating everything at once, hydrate components by priority. Critical interactive components hydrate first; below-the-fold and non-interactive components hydrate later or never.

import { lazy, Suspense } from 'react';

// Critical: hydrates immediately
const SearchBar = lazy(() => import('./SearchBar'));

// Non-critical: hydrates after page is interactive
const ChatWidget = lazy(() => import('./ChatWidget'));

function Page() {
  return (
    <div>
      <SearchBar /> {/* Hydrates first */}
      <main>{/* Static content, no hydration needed */}</main>
      <Suspense fallback={null}>
        <ChatWidget /> {/* Hydrates when idle */}
      </Suspense>
    </div>
  );
}

Use requestIdleCallback to schedule non-critical hydration:

import { hydrateRoot } from 'react-dom/client';

// Hydrate critical content immediately
hydrateRoot(document.getElementById('critical-root'), <CriticalApp />);

// Hydrate non-critical content when browser is idle
requestIdleCallback(() => {
  hydrateRoot(document.getElementById('chat-root'), <ChatWidget />);
});

The critical path hydrates in milliseconds. Users can tap, click, and type immediately. The chat widget appears and becomes interactive seconds later.

Partial Hydration and Islands Architecture

Islands architecture eliminates hydration for static content entirely. Instead of one monolithic SPA, each interactive component is an independent island with its own JavaScript:

<!-- Static HTML, zero JavaScript -->
<article>
  <h1>Article Title</h1>
  <p>Static content rendered on the server.</p>
</article>

<!-- Interactive island: only this component hydrates -->
<div data-island="like-button">
  <button class="like-btn">❤️ 42</button>
</div>

<!-- Another independent island -->
<div data-island="comments">
  <textarea placeholder="Add a comment..."></textarea>
</div>

Frameworks like Astro, Marko, and Qwik implement this natively. Qwik takes it further with resumability—instead of hydrating, it serializes the application state in the HTML and lazily loads event handlers on interaction:

// Qwik: no hydration cost
export default component$(() => {
  return (
    <button onClick$={() => {
      // This handler loads only when the button is clicked
    }}>
      Click me (zero JS until interaction)
    </button>
  );
});

The result: initial JavaScript payloads of 10–30 KB instead of 300+ KB.

Streaming Component-Level JavaScript

Combine streaming HTML with code splitting. Each Suspense boundary can load its JavaScript chunk in parallel with rendering:

<Suspense fallback={<CarouselSkeleton />}>
  <ProductCarousel /> {/* JS chunk loads concurrently with HTML */}
</Suspense>

Configure chunk naming for predictably named lazy chunks:

// webpack config for lazy chunks
output: {
  chunkFilename: '[name].[contenthash:8].chunk.js',
},

Measuring True TTI

Chrome DevTools' Performance panel shows exactly when the main thread becomes responsive. Look for the "Interactive" marker. The gap between "First Paint" and "Interactive" is your hydration delay.

Use the Long Tasks API to measure main thread blocking programmatically:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`Long task: ${entry.duration}ms`, entry.attribution);
    }
  }
});
observer.observe({ entryTypes: ['longtask'] });

Hydration Budgets

Set a hydration budget: no more than 100 KB of JavaScript should hydrate on initial load. Everything else loads lazily or as an island.

| Strategy | Initial JS | TTI on 3G | |----------|-----------|-----------| | Full hydration | 450 KB | 6.2 s | | Progressive hydration | 120 KB | 2.8 s | | Islands architecture | 35 KB | 1.5 s | | Resumability (Qwik) | 12 KB | 1.2 s |

The trend is clear: less hydration, better interactivity. Every kilobyte of JavaScript you don't send for initial hydration is a kilobyte that doesn't block the user from tapping your call-to-action.

Our web development team builds performance-optimized frontends with progressive hydration, islands architecture, and streaming delivery for best-in-class Time to Interactive.