We have 165 playable practice games. Each one is a React component with its own engine, its own state machine, and in some cases its own canvas rendering. They are not small.
Routing from a game id to a component goes through one registry module:
const BalloonGame = dynamic(() =>
import('@/components/games/balloon/BalloonGame').then((mod) => ({ default: mod.BalloonGame }))
);
const ArrowsGame = dynamic(() =>
import('@/components/games/arrows/ArrowsGame').then((mod) => ({ default: mod.ArrowsGame }))
);
// ...163 more
That is the textbook shape. next/dynamic exists precisely so a visitor downloads the one game they are playing rather than all of them.
It was not working. /games/<id> had a first load of about 2.6 MB of JavaScript: every game ever written, shipped to play one.
Why
The registry had no 'use client' directive.
It is a .tsx file full of client components, so it feels like client code, and nothing in the file errors without the directive. But the file that imported it was a Server Component: the game page resolves the game, checks access, and renders.
So the map was evaluated on the server. And that is the whole bug, because of what the bundler can and cannot know.
When a Server Component references client modules, the bundler has to decide what to put in the browser bundle for that route. A server module that references all 165 client components at once gives it nothing to narrow on. It cannot know which branch a request will take, because the branch is a runtime value, so the only correct answer is "all of them". They get placed into one shared chunk group and listed in the route's first load.
The dynamic() calls are still there. They are just decorative at that point. They describe laziness the bundler has already been forced to discard.
The fix is one line and one moved boundary
Add 'use client' to the registry, and look the lookup up from a client component instead:
'use client';
Now the map lives on the client side of the boundary, where next/dynamic behaves the way the docs describe: one async chunk per game, and only the chunk for the game being played is fetched.
The moved boundary is a small client component, GameRunner, which takes a game id as a prop and does the lookup:
<GameRunner gameId={gameName} planTier={plan} />
The Server Component above it still does everything a server should: resolve the game from the library, check entitlements and play budgets, and generate metadata. It just hands a string across the boundary instead of a component.
The consequence worth writing down
A Server Component can no longer call getGameComponent. That is a genuine restriction, and if you do not write it down somewhere the next person reintroduces the bug by doing the natural thing, which is resolving the component where they already resolved the game.
So the file says so, at the top, in the same comment that explains the 2.6 MB:
The consequence for callers: a Server Component can no longer call
getGameComponent. Pass the game id toGameRunnerinstead.
A performance fix that depends on nobody importing a module from the wrong side is not fixed, it is balanced. The comment is what keeps it standing.
How to notice this class of bug
This one is invisible through every normal review path, which is what makes it worth a post.
- The code looks right.
dynamic()is right there in the diff. - Development is fine. Modules are served individually, so the shared chunk group never forms.
- Nothing errors. The page renders, the game plays, the tests pass.
- The page is slow in a way you will attribute to something else, because a games page is full of plausible suspects.
The signal that actually finds it is the build output. next build prints a first load size per route, and a route whose first load is larger than the sum of what that page could possibly need is telling you something specific: some module on the server side of a boundary is referencing more client code than the route can narrow.
Two useful habits came out of this:
- Read the first load column after a build, not just the errors. A number that climbs when you add a feature to a different page is the same symptom in a different disguise.
- Treat "a Server Component imports a module that imports many client components" as a smell on its own, before you measure anything.
We had a sibling version of this same bug a while back, where a client component in the root layout imported a slug list from a 4,400 line constants module and pulled about 100 KB of game metadata into every page on the site. Same family, opposite direction: one crossed the boundary the wrong way, one dragged a module's whole content across with it.
See it
The provider hubs are public. https://cogniprep.app/games/shl lists the suite, and with a free account you can open a game route with the Network tab filtered to JS. You should see the page's own chunks, then exactly one game chunk arriving when the game mounts, and nothing for the other 164.
The general rule I took away: next/dynamic is not a promise, it is a request. Whether it is granted depends on which side of the server boundary the module holding it gets evaluated on, and that is decided by a directive that is easy to leave out of a file that looks entirely like client code.
Top comments (0)