The page looked fine to me, to QA, and to every logged-out visitor. Then a logged-in user on a slower connection described a flicker on load — the header appearing, blanking, and reappearing — and the console, once I reproduced it with the right account, was full of Angular telling me the hydration had failed and it had fallen back to re-rendering the component from scratch. The server had sent one version of the DOM, the browser had built a different one, and Angular did the only safe thing it could: it threw the server's markup away and rebuilt.
The reason it only happened to some users is the whole lesson. The greeting in the header read the user's name from localStorage to say "Welcome back, Dana." On the server, there is no localStorage, so it rendered the generic "Welcome." In the browser, the name was right there, so it rendered the personalized version. Same component, two inputs, two different DOMs — and hydration's one firm rule is that the first thing the browser renders must match what the server sent.
A logged-out user had no name to read, so both renders produced "Welcome," and hydration was happy. The bug was hiding behind exactly the state that made the feature worth building.
Hydration is a contract about the first render, not the final one
It helps to be precise about what hydration is doing. The server sends real HTML so the page is meaningful before any JavaScript runs. Then Angular boots in the browser and, instead of rebuilding that DOM, it adopts it — walks the existing nodes and wires up the bindings in place. That adoption only works if the component renders the same structure the server did. When it doesn't, Angular can't safely reuse the nodes, so it destroys the subtree and re-creates it. That destroy-and-recreate is the flicker. It's also a real performance tax: the server-rendered HTML you paid to produce gets discarded the moment it mattered.
So anything that changes the shape of the first render based on something the server can't see — localStorage, a cookie read in the browser, window, the current time, a random id — is a hydration mismatch waiting for the one user whose value differs from the server's default. The feature works on your machine because your machine has the value. The server never does.
I had been thinking of SSR as "the same app, rendered twice." It's closer to two environments that have agreed to produce an identical first frame, and the agreement breaks the instant my code reads something only one of them has.
Keep the first render neutral; personalize after hydration
The fix is not to abandon the personalization. It's to keep it out of the first render, let the server and client agree on the neutral version, and apply the personal touch after hydration has safely adopted the DOM. Angular gives you a browser-only hook for exactly this — afterNextRender runs only in the browser, only after that first render is committed:
@Component({
template: `<p>{{ greeting() }}</p>`,
})
export class Greeting {
protected readonly greeting = signal('Welcome'); // matches the server
constructor() {
afterNextRender(() => {
const name = localStorage.getItem('displayName');
if (name) this.greeting.set(`Welcome back, ${name}`); // browser-only, after the first render
});
}
}
The server renders "Welcome." The browser's first render also renders "Welcome," because the signal starts there — so hydration adopts the DOM cleanly, no mismatch, no teardown. A tick later, afterNextRender reads localStorage and updates the greeting in place. The user still gets "Welcome back, Dana"; they just get it as a smooth update to a stable DOM instead of a destroyed-and-rebuilt header. The flicker is gone because nothing is being thrown away.
The other fix is worth naming, because it's better when the data exists on the server. If the personalization comes from something the server does know — a session, a logged-in API call — the right move is TransferState: render it personalized on the server, serialize that data into the page, and read it on the client so both renders match from the start. afterNextRender is for the genuinely browser-only values; TransferState is for "the server knew this, don't make the client fetch it again." Reaching for the browser-only hook when the server had the data all along just trades a flicker for a redundant request.
You can watch incremental hydration adopt a server-rendered DOM in place — a below-the-fold panel hydrating on scroll without re-rendering — on the performance demo.
SSR Is Two Environments Agreeing on the First Frame
The mental shift that fixed this was giving up "the same app rendered twice" for something stricter: the server and the browser have to produce an identical first frame, and only afterward are they allowed to diverge. Every browser-only value I read during that first render is a small bet that the server happened to guess the same thing — and that bet comes due in front of the one user whose value is different. Keep the first render neutral, personalize after hydration, and the server-rendered HTML you paid for survives to do its job instead of being torn down at the finish line.


