How do you write a JavaScript module that uses browser-only globals such as `document` but is also imported by server code running in Node, so it does not crash on either host?
answer
- import must be side-effect free
- move host access out of module body
- typeof is the only safe probe
- optional chaining still throws on undeclared
- branch on capability, not on host
basics
~20 sNever touch host globals while the module is being evaluated. Move every browser access inside functions that only the browser calls, and guard those accesses with a capability check such as typeof document !== 'undefined' so importing the module on the server is always harmless.
solid answer
~40 sThe rule is that **importing a module must be side-effect free with respect to the host**. Anything at the top level runs the moment the server imports the file, so a top-level `document.addEventListener(...)` or `const w = window.innerWidth` throws `ReferenceError` during module evaluation and takes the whole import down with it. Move those reads into functions that only browser code paths invoke, and guard with `typeof document !== 'undefined'` — `typeof` is the only check that survives an undeclared identifier, so `if (window)` and even `window?.foo` still throw. Prefer checking the **capability** you actually need (`typeof localStorage !== 'undefined'`) over guessing the host. If a dependency itself touches the DOM at import time, don't import it statically at all — load it lazily from inside the browser-only path.
code
javascript · 13 lineslet cachedWidth = null;
export function getViewportWidth() {
if (typeof window === 'undefined') return null; // server: no viewport
if (cachedWidth === null) cachedWidth = window.innerWidth;
return cachedWidth;
}
export function watchClicks(handler) {
if (typeof document === 'undefined') return () => {};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}go deeper
Remember the one rule that prevents most of these crashes: no browser globals at the top level of a module. Put them inside a function, and test with typeof document !== 'undefined' rather than a bare if.
Explain why evaluation time is what matters — a top-level access throws during import and fails every importer up the chain. Be precise about why typeof works while a bare reference or optional chaining throws.
Show judgment about what the server should return when the capability is missing, including the hydration mismatch that a wrong default causes, and about lazily importing DOM-only dependencies so they never enter the server's module graph.
Own the rule as an enforceable boundary: which packages may touch host globals, where the isomorphic seam sits, and a cheap check that importing shared modules under Node stays clean so this class of failure cannot regress.
## Why import time is the dangerous moment A module body executes exactly once, when it is first evaluated. If a browser-only global is read there, the read happens as a consequence of somebody typing `import './widget.js'` — including the server that renders your page. ```js // widget.js — breaks any Node import of this file const initialWidth = window.innerWidth; // ReferenceError in Node document.addEventListener('click', onClick); export function render() { /* ... */ } ``` The consumer did nothing browser-specific; it merely imported a function. Because module evaluation propagates, the failure is not local: the importing module fails too, and so does its importer. That is why "it crashes on the server" so often points at a single stray top-level line. The fix is structural, not defensive: ```js // widget.js — safe to import anywhere let initialWidth = 0; export function mount(container) { if (typeof document === 'undefined') return; // no host to mount into initialWidth = window.innerWidth; container.addEventListener('click', onClick); } ``` Now importing is inert and only `mount()` requires a browser. ## Why the guard must use typeof An identifier that resolves to no binding throws on **reference**, before any operator or comparison runs: ```js if (window) {} // ReferenceError in Node if (window !== undefined) {} // ReferenceError in Node if (window?.document) {} // ReferenceError in Node — optional chaining does not help if (typeof window !== 'undefined') {} // safe if (globalThis.window) {} // also safe: a missing property is just undefined ``` Optional chaining is the trap people fall into most. `?.` protects you from a value that is `null` or `undefined`; it does nothing about a binding that does not exist. Reading through `globalThis` works because property access on an existing object returns `undefined` for a missing key. ## Detect the capability, not the host `typeof window !== 'undefined'` answers "is there a `window`?", which is not quite "am I in a browser" and definitely not "can I do the thing I want". Ask directly about the API you are about to call: ```js const canObserve = typeof IntersectionObserver === 'function'; const canStore = typeof localStorage !== 'undefined'; ``` Capability checks stay correct as runtimes evolve and as your code runs in contexts that are neither a plain tab nor a plain server process. They also read better: the condition names the reason for the branch. ## Give both hosts something sensible to do A guard that silently returns is fine for effects (mounting, focusing, measuring). For values, decide deliberately what the server should produce, because whatever you return is what gets rendered: ```js export function getTheme() { if (typeof localStorage === 'undefined') return 'light'; // server default return localStorage.getItem('theme') ?? 'light'; } ``` Be aware that a server default which disagrees with the browser value is exactly how hydration mismatches appear; the honest options are to pick a stable default and correct it after the page is interactive, or to pass the real value from the server explicitly. ## When the dependency is the problem Sometimes it is not your code but a package that reads `document` in its own module body. A static `import` of it is enough to break the server, no matter how carefully you guard your own calls. The answer is to keep it out of the static graph and pull it in only on the branch that has a browser: ```js export async function showChart(el) { if (typeof document === 'undefined') return; const { default: Chart } = await import('some-dom-only-lib'); new Chart(el); } ``` Because the specifier is only resolved when that line runs, the server never evaluates the offending module. ## Testing that it holds The cheap regression test is to import the module in a Node context and assert that nothing throws — no DOM emulation, no browser. Anything that fails that test has a host access at evaluation time. Pair it with a browser-side test that the guarded function does its work when a document exists. Two tiny tests keep a whole class of production-only failures from recurring. ## The summary a candidate should give Imports are inert; effects are explicit; guards use `typeof` and name a capability; browser-only dependencies are loaded lazily from inside the browser branch. That is the whole discipline, and it is why the code survives both hosts without a pile of environment flags.
- Why does `window?.document` still throw when there is no window binding?Optional chaining short-circuits on a `null` or `undefined` *value*, but the expression must first resolve the identifier `window`. Resolving an unresolvable reference throws `ReferenceError` before `?.` is ever consulted. Use `typeof window !== 'undefined'` or `globalThis.window?.document`, where the base is an object that definitely exists.
- Your guarded function returns a different value on the server than in the browser. What problem does that create?The server renders one value and the browser computes another, so the markup the client produces on first render disagrees with what arrived — a hydration mismatch, visible as a warning or a flash of corrected content. Pick a stable default that the browser can converge to after mount, or pass the real value down from the server explicitly.
- A third-party package touches `document` in its own module body. What are your options?Keep it out of the static import graph: call `await import('pkg')` inside the browser-only branch so the specifier is only resolved when a document exists. Alternatives are wrapping it behind your own guarded module boundary, or replacing the dependency. No amount of guarding your own calls helps, since a static import evaluates it regardless.
saying these in an interview costs you the question
- Guards with if (window) instead of typeof window
- Believes optional chaining protects an undeclared global
- Puts DOM listeners at module top level and guards later
- Thinks a try/catch around the import is a clean fix
- Assumes the server can just be given a fake document