skip to content

Why does a meta-framework build emit a separate module graph for the browser and for the server?

level: middleimportance: must knowfreq 58%

answer

  1. two sets of roots, two traversals
  2. reachability decides the side
  3. shared module is emitted twice
  4. module state is not shared across graphs

basics

~20 s

Because the two sides have different entry points, targets and permitted dependencies. The browser graph must stay small and carry no secrets or server-only code, while the server graph may use anything, so each is traversed from its own roots.

solid answer

~50 s

A build starts from entry points and follows imports. There are two sets of roots - what the browser boots from, and what handles a request - so there are two traversals and two sets of emitted files. The boundary between them is marked in the source: a module is flagged as belonging to the client side, or treated as server-only, and frameworks differ in the exact marker. The consequences follow from the mechanism. A module reachable from both roots is emitted **twice**, once into each graph, and the two copies hold **separate module-level state**. A server-only dependency reached from a client root either fails the build or is shipped to every visitor, which is how credentials and private endpoints leak. And a runtime environment check does not help: the import was already resolved when the graph was traversed.

go deeper

for a junior

Remember that the browser and the server get different files built from the same project, and that anything the browser's side can reach is downloadable by anyone.

for a middle

Explain traversal from two sets of roots, why a shared module is emitted twice with separate state, and why a runtime check cannot prune an import edge.

for a senior

Show the diagnosis: confirm from the emitted assets which side a module landed on, and choose a structural fix - move it, defer it, or expose it through a request-time endpoint.

for a principal

Treat the boundary as a security control, not a style rule. Decide how it is enforced in review and in the build, since a single import can publish a credential to every visitor.

A bundler does not compile a folder; it starts at entry points and follows imports until it runs out of edges, then emits what it reached. A meta-framework has two distinct sets of entry points - the modules the browser boots from, and the modules that answer a request - so it performs two traversals and emits two bodies of output from one source tree. Almost everything surprising about build output follows from that one fact. ## Two traversals, two outputs The **client graph** is rooted at what the browser loads: the app's client entry plus the per-route entries that a file-based router creates. Its output is the hashed chunks and stylesheets. The **server graph** is rooted at the request handler, the route modules that render or load data, and middleware. Its output is the server bundle. Same source files, two different questions asked of them: *what does a browser need* and *what does a request need*. ## Where the boundary is drawn Source has to say which side a module belongs to, because imports alone cannot tell. The mechanisms vary between meta-frameworks - a marker at the top of a module that makes it a client entry, a naming or directory convention that means server-only, a route module with separately exported server and client parts, a build-time flag that a dependency is external to the browser. Whatever the spelling, the effect is the same: the marker creates or cuts an entry point, and everything follows from reachability. A module lands in a graph because some root of that graph can reach it, not because of where it sits in the project. Three practical consequences of that rule: - **Moving a file changes nothing** unless it changes what can reach it. - **Deleting the last client importer** removes a module from the client output entirely, even though the file still exists. - **One careless import** in a widely used utility can pull a whole subtree across the boundary, because reachability is transitive. ## What follows for shared modules | Situation | What the build does | What you see at runtime | |---|---|---| | Module reached only from client roots | Emitted into client chunks | Runs in the browser only | | Module reached only from server roots | Emitted into the server bundle | Runs per request only | | Module reached from both | Emitted into **both**, as two copies | Two instances, two sets of module state | | Server-only module reached from a client root | Build error, or shipped to browsers | A leak, or a failure at build time | The third row is the one that produces real bugs. A shared module with a top-level cache, counter or registry does not have one instance with two callers; it has two instances that never see each other. Writing on the server and reading in the browser silently reads a fresh, empty copy. The fix is not to force sharing but to pass the value explicitly - serialise it into the response and read it on the other side. ## Why a runtime check does not prune the graph A condition like checking whether a browser global exists runs when the code runs. Graph traversal happens long before that: the import edge is followed, the module is pulled in, and its own imports with it. Some builds can eliminate a branch when the condition is statically known, but you cannot rely on it, and it never helps when the condition depends on anything computed. The honest fixes are structural: put the server-only code in a module that no client root can reach, load it through a dynamic import that only the server side evaluates, or expose its result through a request-time endpoint the browser calls instead. ## What it costs when the boundary is wrong Two failure modes, with very different severity. 1. **Leakage.** A module that reads a credential, a private base URL or a server-side database client gets pulled across, and its contents are now in a file any visitor can download. Treat anything reachable from a client root as published. 2. **Weight.** A large server-only dependency dragged over by a single innocuous import inflates what every visitor downloads, often with no visible symptom other than a route getting slower. ## How to check which side a module landed on The emitted artefacts are the evidence. If a module's distinctive strings appear in a client asset, it is in the client graph, whatever you intended. Frameworks also surface this at build time - a boundary violation usually fails loudly rather than silently dropping the import, precisely because both silent outcomes are bad: dropping it changes behaviour invisibly, and keeping it publishes server code.

  • What is the tell that a server-only dependency has leaked into the client graph?
    Its code is present in an emitted client asset - the module's distinctive strings show up in a downloaded chunk. The softer signal is weight: an unexplained jump in what a route downloads usually means something crossed the boundary. Often the build catches it first, because a server-only capability has no browser equivalent to compile against.
  • A value is computed once on the server and read from a shared module in the browser, and it is always empty. Why?
    Because the shared module was emitted into both graphs, so the browser holds a second, freshly initialised instance rather than the one the server wrote to. Module-level state does not cross the boundary; the value has to be serialised into the response and read from there.

Two shopping lists written from two different recipes: an ingredient that appears on both gets bought twice, and the two kitchens never share the jar.

saying these in an interview costs you the question

  • Thinks one bundle is built and the server runs the same files
  • Believes a runtime environment check removes code from the client bundle
  • Assumes a module imported by both sides shares one instance of its state
  • Thinks a secret is safe because its module is never rendered in the browser
  • Expects the client graph to shrink because a dependency sits in a server branch