One afternoon my mouse froze. Not the app — the whole WSL window. Ctrl+C did nothing. docker ps in another pane just spun. Windows Task Manager showed vmmemWSL sitting at the ceiling and not moving. The only way out was closing every WSL terminal and running wsl --shutdown from PowerShell, then waiting for the VM to fully die before starting it back up.
The trigger, once I found it, was almost funny: I'd added an icon pack for a crypto UI and imported it the "easy" way.
The setup
Next.js 16 on Turbopack, running inside WSL2. The host machine has 16GB of RAM, and I deliberately cap the WSL VM in .wslconfig — a hard memory and swap ceiling — instead of trusting WSL2's default "grow until the host chokes" behavior. Windows needs headroom too; if the VM doesn't have a ceiling, it'll take all of it.
That ceiling is usually a non-issue. next dev compiles routes on demand, memory rises a bit as you click around, and it settles. Until it doesn't.
What actually happened
I needed a handful of chain/token icons and reached for a popular icon package's /dynamic entry point — the one that lets you resolve an icon by symbol at runtime instead of importing each one by name. Convenient. Also, it turns out, a way to hand your dev compiler close to 9,000 modules it didn't need.
Turbopack (like Webpack before it) doesn't know you only want three icons — a dynamic/barrel entry point exposes the whole package as one graph, and the dev compiler has to walk all of it to build the module map, even before you ever click a page that uses it. RSS climbed, GC couldn't keep up, and once we hit the .wslconfig ceiling the VM stopped being responsive to anything, including the shell I was trying to diagnose it from.
The red herring
Here's the part that cost me the most time: next build was completely fine. Green, fast, no warnings.
That's because a production build compiles the graph once and exits — it pays the cost and moves on. next dev instead keeps that graph resident and re-touches it on every file change, for the entire life of the process. A passing build tells you nothing about whether next dev is going to survive an afternoon of work. I'd been checking the wrong signal.
Finding the actual cause
Once I stopped trusting the build and started watching the dev server itself — ps on the next-server process, RSS over time, and which route/import triggered the jump — the pattern was obvious: it wasn't a leak, it was a baseline. The dev server just needed a genuinely huge chunk of memory to hold that module graph, permanently, because that's what the import pulled in.
And it wasn't unique to that one icon package. Any "import one thing from a big indexed package" pattern does this:
import { Icon } from 'some-huge-icon-pack/dynamic' // pulls the whole pack
import { Dialog } from '@radix-ui/react-dialog' // barrel export
import { useReactTable } from '@tanstack/react-table' // same story
Next ships a default list of packages it already knows to optimize (lucide-react is one), but component-level libraries like individual @radix-ui/* packages and @tanstack/* are not on that list by default.
The actual fixes
1. Stop pulling the whole graph for a few icons. Dropped the /dynamic entry point entirely and either imported the specific icons by name or used dependency-free inline glyphs where a handful of icons didn't justify a whole package.
2. Tell Turbopack explicitly what to optimize. In next.config.ts:
experimental: {
optimizePackageImports: [
'lucide-react',
'@tanstack/react-table',
'@tanstack/react-query',
'@radix-ui/react-dialog',
// every other barrel/indexed import actually in use
],
}
This rewrites those imports to their direct file paths at compile time, so the dev compiler only touches what's actually used instead of the whole package. On this repo it took next-server's steady-state RSS from ~3.6GB down to ~1.87GB — about 48% less, just from that one config change. I verified it wasn't a fluke by hammering every route 10x plus sitting idle and watching RSS stay flat afterward — this is a baseline drop, not a leak fix.
One thing that does not help, because it's solving the wrong problem: --max-old-space-size. Turbopack's memory is native Rust, not the V8 heap — bumping the Node heap flag has zero effect on it. The only lever that actually works is fewer/lighter deps plus optimizePackageImports.
3. Cap the blast radius, don't just avoid it. Even with the imports fixed, I kept the explicit .wslconfig memory/swap ceiling. The goal isn't "never hit a limit" — it's "when something does regress, it degrades instead of hanging the whole VM and taking the host down with it."
What I'd tell past-me
- A green
next buildproves nothing aboutnext devhealth — they pay completely different costs. Verify dev separately: hammer routes, then let it idle, and watch RSS actually settle. - Treat any
/dynamicentry point or barrel/index import from a sizable package as a suspect, not a convenience — check what it actually pulls in before wiring it up. - If you're on WSL2, set an explicit memory + swap ceiling in
.wslconfigrather than letting the VM grow unbounded. It won't stop a bad import from being a bad import, but it stops one bad import from being a full-system hang. - If
next devfreezes and nothing responds, don't fight it —wsl --shutdownfrom Windows and start clean. Diagnose after, with the process actually killed.
Top comments (0)