The Next.js Page That Shipped the Database Client to the Browser

The page weighed two megabytes and the network tab showed a database driver going to the browser. A server only import crossed the boundary through one…

Share
The Next.js Page That Shipped the Database Client to the Browser. Abstract performance illustration in orange and dark grey on debugly.dev

The performance review started with a page weight of two megabytes for a page that renders a list, and the network tab held the answer: among the chunks was the database client, complete with its driver, going to the browser, which will never use it, and the source map showed why, a single shared module imported by both a server component and a client component, dragging the server's dependency graph across the boundary. The bundler was not at fault. It shipped exactly the graph the imports described, and the imports were the bug.

I have lost on-call weeks to this exact shape of slow burn, so the order below is the order I fix it in.

This is the anatomy of a server client boundary leak in Next.js, and of the discipline that keeps the server's secrets and weight on the server.

This was Next.js 15.4 with React 19.1, and the leak pattern is the same one that produces the next.js dynamic server usage error, which is why the two fixes look alike.

The boundary, and how a leak crosses it

Next.js splits the world into server components, which may touch the database, the filesystem and the secrets, and client components, which ship to the browser. The split is enforced by the bundler, which builds two graphs: one rendered on the server, one serialised to the browser. A client component may not import a server only module, because everything a client component imports becomes part of the browser graph, and the bundler's job is to include what the graph names, not to judge what it should name.

The leak happens through a module that looks innocent: a file exporting both a client safe helper and a server only function, or a types and utils module that re exports everything. The client component imports the helper, the module's other export references the database client, and the bundler, which cannot tree shake away a module side effect it cannot prove absent, includes the driver in the client graph. The boundary was crossed by a re export, which is the most common smuggler, because a barrel file is a door that opens both ways.

Why it is silent

The build succeeds, the page renders, the tests pass, because nothing is functionally wrong: the browser merely receives code it will not run. The failure is economic, two megabytes of parse and transfer on every visit, and security adjacent, because the shipped driver may carry connection string handling, schema knowledge or, in the worst case, an embedded credential from an environment the build inlined, which is the bundle version of the agent held a token that could delete what it was asked to read: the browser now holds what only the server should.

The silence is why the discipline matters: there is no error to grep for, only a budget that bleeds, which is the payload cousin of the endpoint that spent nine hundred milliseconds building JSON, where the cost is invisible until someone reads the size.

Finding the leak

The first instrument is the bundle analyser, run in CI, which draws the client graph and makes the driver a visible bar, and the bar's import chain is the smuggler's path, readable backwards from the driver to the client component that opened the door.

The second is a budget gate: assert the client bundle size and fail the build on growth beyond a threshold, so the leak is a red check at merge, not a network tab discovery in a review, which is the performance budget discipline from a performance budget that survives contact with a real store applied to the framework bundle.

The third is a grep for the boundary violations the framework already names: client components importing server only packages, and a lint rule that forbids the known server only modules from the client graph, turning the smuggler list into a compile error.

The fixes that hold the line

Split the shared module. The module that exports both a client helper and a server function becomes two modules, and the barrel that re exports both is deleted or narrowed, because the barrel is the door. The client imports only the client module, and the graph closes.

Pass data, not functions, across the boundary. A client component that needs server data receives it as props from a server component, serialised, instead of importing the thing that fetches it. The boundary then carries values, which are safe, rather than references, which drag graphs.

Mark the server only surface. Server only modules get a naming or annotation convention, and the CI lint enforces it, so the smuggling requires an explicit, reviewed exception rather than an accidental re export.

Audit what the build inlined. Environment handling belongs to the server graph, and a client bundle containing a credential is a build configuration defect, caught by scanning the client chunks for secret shapes in CI, the same scan the upload path gets in validating uploads by file extension is not validation in spirit: the artefact leaving the trust boundary is inspected.

If it happens again

The bundler ships the import graph exactly as written, so a server client leak is an import defect, not a bundler defect: a shared module or a barrel that crosses the boundary drags the server's driver, weight and sometimes secrets into the browser, silently, with a green build. Split shared modules, pass values not references across the boundary, lint the smuggler list, and gate the client bundle size in CI.

The two megabytes were never the page's fault. They were the graph's, and the graph is the one artefact in the build that tells the truth about what you asked it to carry, which is why the network tab, read once, is the whole diagnosis.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud