Create observers only in each framework's post-mount, browser-only hook — useEffect/ref callbacks in React, onMounted in Vue, actions or onMount in Svelte, afterNextRender in Angular — and make the first client render match the server's default state, letting observer results change the UI only after hydration has finished.
Problem / Scenario Context
A server-rendered storefront shows a "Back to top" button when the footer is visible and lazy-loads product images. Three bugs appear after enabling SSR: the server crashes with IntersectionObserver is not defined in one component that creates its observer at module top level; another component reads visibility synchronously during render, getting true in the browser where the server rendered false, and the framework logs a hydration mismatch; and a third attaches its observer in a hook that runs before hydration completes, so its first callback updates state while the framework is still reconciling server HTML, producing a flicker.
All three are timing errors. The SSR & Hydration Observer Safety topic covers the landscape; fixing "IntersectionObserver is not defined" in Next.js covers the crash specifically. This page is about the right moment for setup in every major framework.
Mechanics Explanation
An SSR page goes through three phases:
- Server render. Component code runs in Node (or an edge runtime) to produce HTML. There is no
window, no layout, no observers. - Hydration. In the browser, the framework runs components again, expecting to produce the same output as the server, and attaches to the existing DOM instead of creating it. Any difference is a hydration mismatch.
- Post-hydration. Components are live. Effects and mount hooks have run; the DOM is owned by the framework.
Observers belong in phase 3. Their first callback reports the browser's real layout, which the server could not know — so any state they set will differ from the server render. That is fine after hydration: the framework applies it as a normal update. It is a mismatch during hydration.
Each framework has a hook that runs only in the browser and only after the component's DOM is attached:
Comparison Table: The Right Hook per Framework
| Framework | Browser-only, post-mount hook | Runs on server? | Notes |
|---|---|---|---|
| React | useEffect, useLayoutEffect, ref callbacks |
no (warning for layout effect in SSR) | ref callbacks run on attach |
| Vue 3 | onMounted, directive mounted |
no | onServerPrefetch is the server hook |
| Svelte | onMount, actions, $effect |
no | top-level <script> runs on server |
| SolidStart | onMount, refs |
no | component body runs on server |
| Angular | afterNextRender, afterRender |
no | ngOnInit runs on server |
| Web components | connectedCallback |
not with DSD alone | upgrade happens in the browser |
Minimal Reproducible Example
// React: visibility read during render → mismatch.
function BackToTop() {
const footer = typeof document !== 'undefined' ? document.querySelector('footer') : null;
const visible = footer ? footer.getBoundingClientRect().top < window.innerHeight : false;
return visible ? <a href="#top" className="back-to-top">Back to top</a> : null;
}
On the server visible is false; during hydration in a browser scrolled near the footer it is true, so the client render differs and React logs a mismatch.
Production-Safe Solution
Render the server default, then let an observer created after mount update state.
// React
function BackToTop() {
const [visible, setVisible] = useState(false); // server default
useEffect(() => { // browser only, after hydration
const footer = document.querySelector('footer');
if (!footer) return;
const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));
io.observe(footer);
return () => io.disconnect();
}, []);
return <a href="#top" className="back-to-top" hidden={!visible}>Back to top</a>;
}
<!-- Vue -->
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
const visible = ref(false);
let io: IntersectionObserver | undefined;
onMounted(() => {
io = new IntersectionObserver(([e]) => { visible.value = e.isIntersecting; });
io.observe(document.querySelector('footer')!);
});
onBeforeUnmount(() => io?.disconnect());
</script>
<template><a href="#top" class="back-to-top" :hidden="!visible">Back to top</a></template>
// Angular
@Component({ selector: 'app-back-to-top', standalone: true,
template: `<a href="#top" class="back-to-top" [hidden]="!visible()">Back to top</a>` })
export class BackToTopComponent {
readonly visible = signal(false);
constructor() {
const destroyRef = inject(DestroyRef);
afterNextRender(() => {
const io = new IntersectionObserver(([e]) => this.visible.set(e.isIntersecting));
io.observe(document.querySelector('footer')!);
destroyRef.onDestroy(() => io.disconnect());
});
}
}
In each case, server and first client render agree (hidden), the observer starts after hydration, and its first callback updates the UI as an ordinary client update. Rendering the element with hidden rather than not at all keeps the DOM structure identical, which avoids structural mismatches and gives CSS something to transition.
Avoiding a Visible Flash
The server default is sometimes wrong for the user's actual view — a "Back to top" button hidden when the page is restored near the footer, or content rendered in its "not revealed" style. The first observer callback corrects it, typically a frame or two after hydration. To keep the correction invisible:
- Choose defaults that are correct for most visits. Page loads usually start at the top; "not near the footer" is the right default.
- Prefer defaults that fail safe. Content should default to visible; animations should start only after JS confirms it is running, as in fade in on scroll.
- Use CSS for anything CSS can know. Sticky positioning, container queries and scroll-driven animations are resolved in the first paint, before any JavaScript.
- Hydrate lazily where possible. Components below the fold that hydrate on visibility never show a default-then-correct transition, because they only hydrate when they are about to be seen — see lazy hydrating Nuxt components on visibility.
Verification Steps
- Run the server build and confirm no
ReferenceErrorfor observer APIs. - Check the console on hydration for mismatch warnings with the page restored at different scroll positions.
- Disable JavaScript and confirm the server defaults are usable.
- Record a trace and confirm observers are created after hydration, not during it.
- Navigate client-side to the page and confirm the same setup path works without SSR.
Common Mistakes to Avoid
- Creating observers at module top level. It runs on the server and at import time.
- Reading layout during render to decide what to render.
- Using
ngOnInit, Vuesetuptop level or Svelte<script>top level for browser APIs; they run on the server. - Conditionally rendering structure from observer state on first render. Render the default structure; toggle attributes after.
FAQ
Why can't I just check typeof window in render?
The check prevents the crash but makes server and client renders differ whenever the browser branch produces different output, which is a hydration mismatch. Observers must influence rendering only after hydration.
Is useLayoutEffect better than useEffect for observers?
Either works; observers deliver asynchronously anyway. useLayoutEffect runs before paint and warns during SSR in older React versions. A ref callback is often the cleanest, since it runs exactly when the element attaches.
Does the first observer callback cause a second render?
Usually yes, when its state differs from the default. That is an ordinary client update after hydration, not a mismatch. Choosing good defaults keeps it rare.
What about islands or partial hydration frameworks?
The same rule applies per island: create observers in the island's client hook after it hydrates. Frameworks such as Astro also offer visibility-based hydration directives that use an observer internally.
Do web components have this problem?
Custom elements are defined in the browser, and connectedCallback only runs there. With Declarative Shadow DOM the server can render their shadow content, and the observer starts on upgrade — the same default-then-truth pattern.
How do I test for hydration mismatches?
Run end-to-end tests against the server-rendered build, fail on console errors, and load pages at several scroll positions and viewport sizes, since observer-dependent state varies with both.
Related
- Hydration Mismatch from Observer-Driven Class Toggles — the mismatch in detail
- Progressive Enhancement Fallbacks for Observer-Driven UI — defaults that work without JS
- Observers in React Server Components and Client Boundaries — where client code lives
↑ Back to SSR & Hydration Observer Safety