AngularHydrationPerformanceSSRSignals

Angular Hydration: Achieving 100/100 Lighthouse Scores

P
Performance Expert
Featured Guide 25 min read

Don't Rebuild. Resume.

Prior to Angular 17, "Hydration" meant destroying the server-rendered HTML and rebuilding the entire DOM from scratch. It was flickering and slow.

Modern Angular Non-Destructive Hydration reuses existing DOM nodes and attaches event listeners surgically.

Deep Dive: Progressive vs Partial

Progressive Hydration: Hydrating the page chunk-by-chunk over time (usually priority-based). All JS eventually runs.
Partial Hydration (`@defer`): Non-interactive parts (like this text block) never hydrate. Their JS code is never downloaded. The HTML stays static forever.

02. Partial Hydration (@defer)

Why load the JavaScript for a footer that is off-screen? With @defer blocks, Angular allows you to hydrate only the critical parts of the application first, and lazy-load/hydrate the rest on interaction or visibility.

// main.component.html
@defer (on viewport) {'{'}
  <heavy-chart-component />
{'}'} @placeholder {'{'}
  <div>Loading...</div>
{'}'}

03. Event Replay

The Uncanny Valley

Between the time HTML loads and JS executes, users often click buttons. In old frameworks, these clicks were lost.

Angular's Solution

Angular captures these events using a tiny inline script and replays them once the application hydrates. Zero lost interactions.

04. The Senior Engineer's Take

Lighthouse isn't everything.

While hydration improves metrics like LCP (Largest Contentful Paint) and CLS (Cumulative Layout Shift), the real winner is INP (Interaction to Next Paint).

By keeping the main thread free from heavy DOM reconstruction, Angular ensures your app feels responsive even on low-end mobile devices.

âš¡ Interactive Playground

import React, { useState, useEffect } from 'react';

// 💧 Hydration Simulator

export default function HydrationDemo() {
    // Stages: 'html' -> 'js-loading' -> 'hydrated'
    const [stage, setStage] = useState('html'); 
    const [clicks, setClicks] = useState(0);
    const [capturedClicks, setCapturedClicks] = useState(0);

    useEffect(() => {
        let timer1, timer2;
        if (stage === 'html') {
            // Simulate 2s delay for JS bundle
            timer1 = setTimeout(() => {
                setStage('js-loading');
                timer2 = setTimeout(() => {
                    setStage('hydrated');
                }, 1500); // 1.5s Execution time/Hydration
            }, 2000);
        }
        return () => { clearTimeout(timer1); clearTimeout(timer2); };
    }, [stage]);

    useEffect(() => {
        if (stage === 'hydrated' && capturedClicks > 0) {
            // Replay events!
            const interval = setInterval(() => {
                setCapturedClicks(prev => {
                    if (prev <= 0) {
                        clearInterval(interval);
                        return 0;
                    }
                    setClicks(c => c + 1);
                    return prev - 1;
                });
            }, 200);
            return () => clearInterval(interval);
        }
    }, [stage, capturedClicks]);

    const handleUserClick = () => {
        if (stage === 'hydrated') {
            setClicks(c => c + 1);
        } else {
            // Capture event in replay buffer
            setCapturedClicks(c => c + 1);
        }
    };

    return (
        <div className="bg-slate-50 dark:bg-slate-950 p-8 rounded-3xl border border-slate-200 dark:border-slate-800 shadow-xl">
             <div className="flex justify-between items-center mb-10">
                <h3 className="text-2xl font-black text-gray-900 dark:text-white flex items-center gap-3">
                    <span className="text-blue-500 text-3xl">💧</span> Partial Hydration
                </h3>
                <button 
                    onClick={() => { setStage('html'); setClicks(0); setCapturedClicks(0); }}
                    className="text-xs bg-slate-200 dark:bg-slate-800 px-3 py-1 rounded-lg hover:bg-slate-300 transition"
                >
                    Restart Simulation
                </button>
            </div>

            <div className="grid grid-cols-1 md:grid-cols-2 gap-12">
                
                {/* Visualizer */}
                <div className="relative mx-auto w-full max-w-[300px] aspect-[9/19] bg-gray-900 rounded-[3rem] border-8 border-gray-800 shadow-2xl overflow-hidden flex flex-col">
                    {/* Phone Screen */}
                    <div className="bg-white dark:bg-slate-950 flex-1 relative flex flex-col">
                        
                        {/* Status Bar */}
                        <div className="h-6 bg-black text-white text-[10px] flex justify-between px-4 items-center">
                            <span>9:41</span>
                            <div className="flex gap-1">
                                <span>📶</span>
                                <div className="w-4 h-2 bg-white rounded-sm"></div>
                            </div>
                        </div>

                        {/* Content */}
                        <div className="flex-1 p-4 flex flex-col items-center justify-center space-y-4">
                            
                            <div className={`w-24 h-24 rounded-full flex items-center justify-center transition-all duration-500 ${stage === 'hydrated' ? 'bg-green-500 scale-110' : 'bg-gray-200 grayscale'}`}>
                                <span className="text-4xl text-white">âš¡</span>
                            </div>

                            <h2 className="font-bold text-xl text-center text-gray-800 dark:text-white">
                                {stage === 'hydrated' ? 'Interactive!' : 'Loading...'}
                            </h2>
                            
                            <button
                                onClick={handleUserClick}
                                className="w-full py-4 bg-blue-600 text-white rounded-xl font-bold active:scale-95 transition-transform shadow-lg relative overflow-hidden"
                            >
                                Buy Now ({clicks})
                                {stage !== 'hydrated' && (
                                    <div className="absolute inset-0 bg-white/20 animate-pulse"></div>
                                )}
                            </button>
                            
                        </div>

                        {/* Debug Overlay */}
                        <div className="absolute bottom-0 w-full bg-black/80 text-white p-3 text-xs font-mono space-y-1 backdrop-blur-md">
                            <div className="flex justify-between">
                                <span className="text-gray-400">Stage:</span>
                                <span className={stage === 'hydrated' ? 'text-green-400' : 'text-yellow-400'}>{stage.toUpperCase()}</span>
                            </div>
                            <div className="flex justify-between">
                                <span className="text-gray-400">JS Bundle:</span>
                                <span>{stage === 'html' ? 'Pending' : 'Loaded'}</span>
                            </div>
                             <div className="flex justify-between">
                                <span className="text-gray-400">Replay Buffer:</span>
                                <span className="text-orange-400">{capturedClicks} events</span>
                            </div>
                        </div>

                    </div>
                </div>

                {/* Explanation */}
                <div className="space-y-6 flex flex-col justify-center">
                     <div className={`p-6 rounded-2xl border transition-all duration-500 ${stage === 'html' ? 'bg-blue-50 border-blue-200 scale-105 shadow-md' : 'bg-white opacity-50'}`}>
                         <h4 className="font-bold text-lg mb-2 flex items-center gap-2">
                             1. Server HTML
                         </h4>
                         <p className="text-sm text-gray-600">The browser renders the layout instantly (LCP). The button looks clickable, but JS hasn't downloaded yet.</p>
                     </div>
                     
                     <div className={`p-6 rounded-2xl border transition-all duration-500 ${stage === 'js-loading' ? 'bg-orange-50 border-orange-200 scale-105 shadow-md' : 'bg-white opacity-50'}`}>
                         <h4 className="font-bold text-lg mb-2 flex items-center gap-2">
                             2. Event Replay
                         </h4>
                         <p className="text-sm text-gray-600">User clicks "Buy Now". Angular captures the event in a tiny 1kb inline script instead of ignoring it.</p>
                     </div>

                     <div className={`p-6 rounded-2xl border transition-all duration-500 ${stage === 'hydrated' ? 'bg-green-50 border-green-200 scale-105 shadow-md' : 'bg-white opacity-50'}`}>
                         <h4 className="font-bold text-lg mb-2 flex items-center gap-2">
                             3. Hydration Complete
                         </h4>
                         <p className="text-sm text-gray-600">JS executes. Angular attaches listeners and "replays" the buffered user clicks so the state updates correctly.</p>
                     </div>
                </div>

            </div>
        </div>
    );
}