Skip to content
Chapter 6Lesson 2

Walking the graph — evaluation, live bindings, and the client bundle

How JavaScript's module system actually runs your imports, evaluation order, live bindings, circular dependencies, code splitting, and the directives that keep server code out of the browser bundle.

This lesson exists to prevent a particular kind of debugging session. Suppose you shipped two utility modules: lib/auth.ts reads cookies and queries the database, and lib/format.ts is a pure date formatter. A client component pulled in a helper that re-exported both. The browser now downloads 800KB of database driver, code the user will never run, because the bundler walked from your client component through every reachable edge.

The previous lesson framed each import as an edge in a directed graph. This lesson covers what the runtime and bundler actually do with that graph, and it comes down to four ideas. The runtime walks the graph depth-first and evaluates each module once. Imports are live bindings rather than value copies, which is why cycles can cause trouble. Dynamic import() is the only way to draw a deferred edge. And 'use client' together with 'server-only' turn the client bundle into a subgraph the build enforces.

Depth-first, once per module: how the graph runs

Section titled “Depth-first, once per module: how the graph runs”

Begin with the order in which the runtime evaluates your modules. Once you have seen it, the reason a console.log at the top of a leaf module runs before a console.log at the top of the file that imported it stops being a surprise.

The runtime walks the graph from your entry module, follows each import down to its target, recurses into that target’s imports, and keeps recursing. A module’s top-level code runs only after all of its imports have finished evaluating. Leaves run first, the root runs last.

flowchart LR
  page["⑥ page.tsx"]
  auth["③ auth.ts"]
  format["⑤ format.ts"]
  db["② db.ts"]
  env["① env.ts"]
  temporal["④ temporal.ts"]

  page --> auth
  page --> format
  auth --> db
  db --> env
  format --> temporal
The runtime walks from `page.tsx` depth-first; numbered badges mark the evaluation order. Leaves finish before their importers, so `env.ts` runs first and `page.tsx` runs last. Each module runs exactly once.

Three rules fall out of that picture, and you will lean on all three for the rest of the course.

Depth-first, post-order. A module’s top-level code runs after all of its imports finish. In the diagram, no top-level statement inside auth.ts can execute until db.ts has finished evaluating and its exports exist. That is what “post-order” means: visit the children, then the node.

Once per module. The same file imported from two places does not run twice. The runtime keeps a cache keyed by the resolved file path, and the second import reuses the first evaluation. Every top-level const and every module-level variable is shared across all consumers. This is what makes the module-level singleton pattern in the next lesson work: a let cached: Db | null = null at module scope really is one slot for the whole app.

Errors short-circuit upward. A throw at the top of a leaf module prevents every upstream consumer from finishing. If env.ts throws because a required environment variable is missing, db.ts never gets its imports, auth.ts never runs, and your page never renders. That behavior is desirable, because it is the mechanism behind fail-closed startup validation: bad configuration stops the app at boot instead of surfacing mid-request.

Live bindings: imports point at the exporter’s variable

Section titled “Live bindings: imports point at the exporter’s variable”

Next, consider what the import statement actually gives you. The answer surprises most people the first time they meet it, and it is the least obvious rule in ES modules. Getting it clear now is what makes the circular-dependency story later make sense.

An import is not a copy. It is a read-only window onto the exporter’s variable. When the exporter mutates the variable, the importer sees the new value. This happens not because some framework wired up an observer, but because the ES module spec defines imports to work this way at the binding level.

The clearest way to see this is to put what actually happens next to the mental model most newcomers bring.

counter.ts
export let count = 0;
export const increment = () => {
count += 1;
};
consumer.ts
import { count, increment } from './counter';
console.log(count); // 0
increment();
console.log(count); // 1 — the import tracks the exporter

count in consumer.ts is a binding to counter.ts’s variable. The second log reads 1 because the import never snapshotted the value, it follows the exporter instead. The green-marked lines are where the mutation flows through the edge: increment() writes the exporter’s binding, and the next read in the consumer sees the new value.

Two consequences follow, and both come up in real codebases.

Re-exports preserve the live binding. export { count } from './counter' does not snapshot; the re-exporting module simply re-exposes the binding. A consumer importing count through three layers of re-export still tracks the original variable in counter.ts.

The importer cannot reassign the binding. Inside the consumer, count = 5 is a compile-time error. Only the exporter writes, and everyone else reads. That is what “read-only window” means in practice, and the runtime enforces the asymmetry.

A note on the legacy contrast: CommonJS require() reads module.exports once and copies it. If you have seen that behavior in older Node code, set it aside, because modern ESM does not work that way.

Circular dependencies: when the graph loops back on itself

Section titled “Circular dependencies: when the graph loops back on itself”

A cycle is just the live-binding story applied to an import graph that loops back on itself. Once you understand evaluation order and live bindings, circular dependencies stop being mysterious. Their behavior is predictable: one clear set of cases crashes, and another clear set resolves.

A cycle crashes when one module reads another’s export at the top level before that export has been assigned. Picture two files that import each other, where each one tries to read the other’s export immediately rather than from inside a function.

a.ts
import { fromB } from './b';
export const fromA = fromB + 1; // fromB is undefined when this runs
b.ts
import { fromA } from './a';
export const fromB = fromA + 1;

Walk the runtime through it. Some entry module imports a.ts. a.ts starts evaluating and immediately reaches import { fromB } from './b'. The runtime starts evaluating b.ts. b.ts reaches import { fromA } from './a'. The runtime sees that a.ts is already mid-evaluation, so its fromA export has not been assigned yet, and it hands b.ts the partial module. b.ts reads fromA, gets undefined, and the arithmetic coerces that undefined to NaN. The runtime does not error on the cycle itself; the real bug is the top-level read of a value that was not ready.

Not every cycle crashes. Two shapes resolve without complaint.

Function-body access. If b.ts only reads fromA inside the body of a function, the cycle is harmless. By the time anyone calls that function, both modules have finished evaluating and the live binding points at a real value. The problem is specifically a read at the top level, so deferring the read into a function call avoids the partial-module window entirely.

Type-only cycles. import type is erased at compile time. A type-level cycle exists only inside the TypeScript type checker, which resolves such cycles cleanly, and it never reaches the runtime at all. So when two modules genuinely need each other’s types, converting one of the imports to import type dissolves the cycle, because the runtime graph no longer has that edge.

The experienced fix: extract the shared module

Section titled “The experienced fix: extract the shared module”

When a value-level cycle does appear, the structural fix is to pull the shared symbol into a third module, so that neither a.ts nor b.ts imports the other as a value. The cycle then becomes a Y-shape: both a.ts and b.ts depend on shared.ts, and neither depends on the other. The runtime evaluates shared.ts first, then a.ts and b.ts in either order. Compare the two layouts:

  • Directorysrc/
    • entry.ts
    • a.ts imports fromB from b.ts
    • b.ts imports fromA from a.ts

a.ts and b.ts import each other. Whichever module evaluates first hands the other a partial view, so top-level reads return undefined.

This Y-shape pattern shows up again in Drizzle’s relations API, where one shared file holds the relation declarations both tables reference. You will see it used deliberately once the data layer arrives.

Deferred edges: dynamic import() and code splitting

Section titled “Deferred edges: dynamic import() and code splitting”

The previous lesson introduced import(), the function-call form rather than the statement, as a value-level expression that returns a Promise<Module>. This section covers its bundling consequence, which is where the feature earns its keep.

A normal static import draws what the bundler treats as an eager edge. The target module belongs in the same chunk as the importer; its bytes ship in the initial JavaScript download.

import { renderChart } from './heavy-chart';

If heavy-chart.ts and its dependencies weigh 200KB, that 200KB is part of the initial bundle the browser parses on first page load, whether the user ever looks at the chart or not.

import() as an expression draws a deferred edge. The bundler emits the target as a separate chunk, fetched only when the expression actually runs. The cost moves: you pay one extra network round-trip the first time the code path executes, in exchange for not shipping the bytes up front.

const onAnalyticsTabClick = async () => {
const { renderChart } = await import('./heavy-chart');
renderChart();
};

Now heavy-chart’s 200KB sits in its own file on the CDN. The initial bundle does not carry it. The first time a user clicks the analytics tab, the browser fetches the chunk; subsequent clicks reuse the cached copy. This is code splitting , and the dynamic import() expression is its trigger.

One point worth getting straight: await is not what created the chunk. The bundler sees the import() form at build time and splits on that. Adding await in front of a static import does not split anything. People do try that, and the reason it fails is that the split depends on the import() expression, not on the await.

Two situations call for it, and one common case does not:

  • Heavy and rarely used. A chart library inside a settings page, or a markdown editor inside an admin tool. The bytes are real, and most sessions never reach them.
  • Conditional. A locale-specific date module loaded based on the user’s locale. A static import would force every locale into every bundle, whereas the dynamic import loads just one.
  • Not for page-to-page splits. Next.js App Router route segments split automatically, since the framework already emits per-route chunks. Dynamic import() is the tool for splitting a component or feature flag inside a page, not for the navigation graph itself.

When the dynamic target is a React component, Next.js ships a wrapper called next/dynamic that pairs import() with Suspense and adds SSR controls. The ssr: false option is the one you reach for when a component touches window, localStorage, or another browser-only API and must skip server rendering. You will see it again in Unit 4, where client components are covered in depth. For now, recognize it as the React-aware shape of the same import() idea.

The bundle boundary: 'use client', 'server-only', 'client-only'

Section titled “The bundle boundary: 'use client', 'server-only', 'client-only'”

The earlier sections taught how the graph runs. This one teaches the rule that decides which subgraph ships to the browser. There are three directives, each with one job.

'use client' marks an entry into the client bundle

Section titled “'use client' marks an entry into the client bundle”

A file beginning with 'use client'; is a client entry point. The bundler treats it as a root of the client subgraph: every module reachable from it through static imports ships to the browser. Server Components, the default in the App Router, need no directive.

Where you place the directive matters. It belongs on the smallest interactive leaf, meaning the actual button, form, or piece of state that needs the browser. Putting it on a parent layout drags the entire subtree into the client bundle, including any server-only helper that subtree happens to import. The App Router gives this its full treatment in Unit 4; for this lesson, the rule is enough.

'use client';
import { useState } from 'react';
export const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

That file is a graph root. The bundler crawls every static import from it, and every module it reaches must be safe to run in the browser.

import 'server-only' makes leaks a build error

Section titled “import 'server-only' makes leaks a build error”

A server-only module is anything that touches secrets, the database, request cookies, request headers, or an SDK initialized with a private key. The convention is to mark such modules with import 'server-only'; as the first line.

import 'server-only';
import { db } from '@/db';
export const getCurrentUser = async () => {
// reads the session cookie and queries the database
};

The server-only package is a no-op at runtime in Node. Its real job happens at build time: if a client bundle ever reaches this file, the build fails with an explicit error naming the offending import chain. The cost is one line, and in return server code accidentally shipping to the browser becomes structurally impossible.

This line is enforcement, not documentation. The convention runs throughout the course’s codebase: env.ts, the Drizzle client, the Better Auth instance, and every billing, email, and storage adapter starts with it.

This is the symmetric package, used on modules that touch window, localStorage, or any browser-only API and would crash if rendered on the server. Most browser-only code already lives inside a 'use client' file, where it is safe by construction, so you reach for 'client-only' less often than 'server-only'. The right place for it is a utility module that should refuse to be imported from a Server Component: a wrapper around window.matchMedia, for instance, that has no business running in a Node process.

The “looks fine, ships everything” trap: a worked example

Section titled “The “looks fine, ships everything” trap: a worked example”

Now the 800KB story from the opening makes sense. A single file, written without thought to the bundle boundary, can pull all of its server-side dependencies into the browser the moment one client component imports a single harmless-looking helper from it. The fix is structural: split the file by responsibility, and let 'server-only' enforce the cut.

lib/utils.ts
import { headers } from 'next/headers';
import { db } from '@/db';
export const formatDate = (d: Date) => d.toISOString().slice(0, 10);
export const getCurrentUser = async () => {
await headers();
// db query reading the session cookie...
return db.query.users.findFirst(/* simplified for the example */);
};

A client component imports only formatDate. The bundler still walks every reachable edge, including db, headers, and the Drizzle relations graph, so all of that server code comes along. The client bundle fills up with code the user never executes. The red-marked lines are the server-only seams that should not be reachable from the browser at all.

The rule to carry forward is this: pure utilities live in their own files, and anything that touches a server seam imports 'server-only'. The file-per-responsibility split is not about aesthetics. It is how the bundler knows where the client subgraph ends.

Five short snippets, each exercising one of the lesson’s central ideas. Pick the outcome before reading the explanation.

Given these two files:

counter.ts
export let count = 0;
export const increment = () => {
count += 1;
};
consumer.ts
import { count, increment } from './counter';
console.log(count);
increment();
console.log(count);

What does consumer.ts log?

Logs 0 then 1
Logs 0 then 0
TypeError on the second log

Given these two files:

a.ts
import { fromB } from './b';
export const fromA = fromB + 1;
b.ts
import { fromA } from './a';
export const fromB = fromA + 1;

An entry module imports a.ts. What happens?

Produces NaN at runtime
Both exports settle to 1
The build fails before the code ever runs

Same shape as the previous cycle, but converted to type-only imports:

a.ts
import type { TypeB } from './b';
export type TypeA = { b: TypeB };
export const valueA = 42;
b.ts
import type { TypeA } from './a';
export type TypeB = { a: TypeA };
export const valueB = 7;

A consumer imports valueA from a.ts and logs it. What happens?

Logs 42 — no runtime cycle exists
Crashes at runtime
Build error

Given these two files:

app/_components/dashboard.tsx
'use client';
import { getCurrentUser } from '@/lib/auth';
export const Dashboard = () => {
// ...
};
lib/auth.ts
import 'server-only';
import { db } from '@/db';
export const getCurrentUser = async () => {
// reads session, queries db
};

What happens when you build?

Build error naming the import chain that leaked
Builds and runs — server-only is just a documentation hint
Builds, then crashes at runtime in the browser

Given this client component:

app/_components/analytics-tab.tsx
'use client';
export const AnalyticsTab = () => {
const onClick = async () => {
const { renderChart } = await import('./heavy-chart');
renderChart();
};
return <button onClick={onClick}>Open analytics</button>;
};

Does heavy-chart’s code ship in the initial bundle?

No — the bundler emits it as a separate chunk fetched when the click handler runs
Yes — await import is just an await in front of a regular import
Only if the user has JavaScript enabled