You’ve seen it. A server-rendered page loads instantly, the buttons are right there, you click one — and nothing happens. You click again. Still nothing. Then, a second or two later, everything springs to life at once. That dead zone between “looks ready” and “is ready” has a name: the Uncanny Valley of SSR.
I’ve shipped apps where users genuinely thought the page was broken during that gap. In healthcare SaaS, this isn’t just a UX annoyance — when a dental practitioner is mid-appointment and a patient portal takes two seconds to become interactive, trust erodes fast. After years of building SPAs that were never server-rendered, Angular’s SSR story finally makes it practical to address this without rewriting everything. Let me walk through how.
1. The Problem: Full Hydration Is All or Nothing
Traditional server-side rendering in Angular works in two phases. The server sends fully rendered HTML so the user sees content fast. Then the client downloads the entire JavaScript bundle, bootstraps every component, and attaches event listeners. Only then is the page interactive.
During that middle phase — HTML visible, JS not ready — clicks go nowhere. Forms don’t submit. Accordions don’t open. The page lies to the user. In patient-facing portals, where people are checking treatment plans or upcoming appointments, that first paint speed directly impacts user trust. If the page looks ready but doesn’t respond, patients assume something is wrong.
Full hydration doesn’t help. It just means Angular walks the entire DOM tree and rehydrates every component at once. For a small app, fine. For a dashboard with dozens of widgets, charts, and nested components, that’s a lot of JavaScript executing before anything responds.
2. @defer Blocks and Their Triggers
Angular’s @defer blocks let you declare exactly when a chunk of template should load. This was originally designed for lazy loading, but it becomes the foundation for incremental hydration.
@defer (on viewport) {
<app-pricing-table />
} @placeholder {
<div class="skeleton-pricing"></div>
} @loading (after 200ms; minimum 500ms) {
<app-spinner />
} @error {
<p>Failed to load pricing data.</p>
}
The available triggers cover every scenario I’ve needed:
on viewport— loads when the placeholder scrolls into viewon hover— loads when the user hovers over the placeholderon interaction— loads on click, keydown, or similaron idle— loads when the browser is idle (requestIdleCallback)on timer(3s)— loads after a fixed delayon immediate— loads right away, but still lazy-bundled
You can combine multiple triggers, and this is where it gets interesting.
3. Anti-Flicker Controls
Nothing looks worse than a spinner that flashes for 50 milliseconds. Angular’s @loading block has two timing controls that prevent this:
@defer (on viewport) {
<app-heavy-widget />
} @loading (after 200ms; minimum 500ms) {
<app-spinner />
}
after 200ms means the spinner won’t appear unless loading takes longer than 200ms. If the bundle loads in 150ms, the user never sees a spinner at all.
minimum 500ms means if the spinner does appear, it stays visible for at least 500ms. No flash-and-disappear. These two properties together make the loading experience feel intentional rather than glitchy.
4. Prefetch and Hydrate Are Independent
Here’s a non-obvious detail that changed how I structure deferred content. Prefetching and triggering are two separate concerns:
@defer (on viewport; prefetch on immediate) {
<app-hero-carousel />
} @placeholder {
<div class="carousel-skeleton"></div>
}
This starts downloading the bundle for app-hero-carousel immediately when the page loads. But it only swaps in the live component when the placeholder enters the viewport. You get the network cost out of the way early without paying the rendering cost until the user actually needs it.
I use this pattern for anything below the fold that I know the user will scroll to. The bundle is cached and ready; the swap is instant.
5. Event Replay: No Lost Clicks
Angular adopted event replay from Google’s internal Wiz framework, and it solves the other half of the Uncanny Valley problem.
A small inline script — injected before any framework code loads — starts recording user interactions the moment the server-rendered HTML appears. Clicks, keypresses, form inputs. Everything gets captured in a lightweight queue.
After hydration completes, Angular replays those events against the now-live components. The user clicked a button at 200ms, hydration finished at 800ms, and the click handler fires at 801ms. From the user’s perspective, the page just responded.
To enable it alongside incremental hydration:
// app.config.ts
import { provideClientHydration, withIncrementalHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withIncrementalHydration(),
withEventReplay()
),
],
};
This is one of those features where the absence of a problem is the whole point. Users stop experiencing the dead zone. They just don’t notice hydration at all.
6. Incremental Hydration with @defer
With incremental hydration enabled, @defer blocks gain a hydrate clause. Server-rendered HTML stays static — no JavaScript attached — until the hydration trigger fires:
@defer (on viewport; hydrate on hover) {
<app-product-card [product]="product" />
} @placeholder {
<div class="product-skeleton"></div>
}
The server renders app-product-card as real HTML. The user sees the card with all its content. But it’s inert — just DOM nodes with no event listeners. When the user hovers over it, Angular hydrates that specific component, attaches handlers, and it becomes fully interactive.
The critical distinction: the hydrate clause only affects initial server-rendered pages. If the user navigates client-side via the router, Angular renders the component entirely on the client — no hydration needed. This means you don’t have to worry about the hydration triggers interfering with SPA navigation.
For content that truly never needs interactivity — a rendered markdown block, a static footer, a legal disclaimer:
@defer (hydrate never) {
<app-legal-footer />
}
The hydrate never directive means that component stays as static HTML forever. Angular won’t hydrate it, won’t attach listeners, won’t execute its TypeScript. Even nested @defer blocks inside it remain static. This is a real performance win for content-heavy pages.
7. Hybrid Routing with Per-Route Render Modes
Incremental hydration pairs naturally with Angular’s hybrid routing, where each route can declare its own render strategy:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'blog/:slug', renderMode: RenderMode.Prerender, async getPrerenderParams() {
const posts = await fetchBlogSlugs();
return posts.map(slug => ({ slug }));
}},
{ path: 'dashboard', renderMode: RenderMode.Server },
{ path: 'login', renderMode: RenderMode.Client },
];
RenderMode.Prerender generates static HTML at build time. Use getPrerenderParams() to supply route parameters for dynamic paths like blog posts or product pages.
RenderMode.Server renders on every request. Good for personalized or frequently changing pages.
RenderMode.Client skips the server entirely. The page renders in the browser. I use this for auth flows where server rendering adds complexity with no benefit.
Having worked across both web and mobile, I’ve developed a strong sense for when SSR matters and when it doesn’t. Mobile apps never had SSR and nobody missed it. The lesson is that SSR is about the specific characteristics of web delivery: slow networks, SEO, shareable URLs. For internal dashboards or authenticated app shells, client rendering is still the right default. The power of hybrid routing is that you no longer have to pick one strategy for the whole app.
8. Practical Considerations
A few things I’ve learned shipping apps with these features.
ngSkipHydration is your escape hatch. If a component directly manipulates the DOM — maybe it integrates a vanilla JS charting library or a third-party widget — hydration will fail because the DOM won’t match what Angular expects. Slap this attribute on and Angular skips hydration for that subtree:
<app-legacy-chart ngSkipHydration />
withFetch() matters for SSR. Angular’s HttpClient defaults to XMLHttpRequest, which doesn’t exist on the server. Adding withFetch() switches to the Fetch API, which works in both environments:
provideHttpClient(withFetch())
Platform-specific implementations via DI are cleaner than isPlatformBrowser() checks scattered through your code. Define an abstract class, provide a server implementation and a client implementation, and let the injector pick the right one:
abstract class StorageService {
abstract getItem(key: string): string | null;
abstract setItem(key: string, value: string): void;
}
// Browser implementation uses localStorage
// Server implementation uses an in-memory map
// Provided conditionally in app.config.server.ts vs app.config.ts
9. Putting It All Together
The Uncanny Valley in SSR was never a rendering problem. It was an interactivity budget problem. Full hydration forces you to pay the entire cost upfront. Incremental hydration lets you spread that cost across time and user intent. For healthcare SaaS, where practitioners need instant response during patient interactions, that budget allocation is the difference between a tool that helps and a tool that gets in the way.
A page I recently built has a hero section, a pricing table, a feature comparison grid, testimonials, and a footer. With incremental hydration, only the hero hydrates on load. The pricing table hydrates on viewport. The feature comparison hydrates on hover. The testimonials hydrate on idle. The footer never hydrates.
Total JavaScript executed on initial load dropped by over 60%. Time to Interactive went from 2.8 seconds to under 1 second. And not a single user click was lost, because event replay caught everything in between.
The Uncanny Valley is dead. Angular buried it.