Why does a meta-framework build emit a separate module graph for the browser and for the server?
answer
- two sets of roots, two traversals
- reachability decides the side
- shared module is emitted twice
- module state is not shared across graphs
basics
~20 sBecause 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 sA 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
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.
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.
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.
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