
Next.js Optimizing your local development environment
Every Next.js developer eventually hits the same wall: the app used to reload in a blink, and now every save takes a beat too long. You edit a component, switch to the browser, and wait. Not a dramatic wait, just enough to break your flow. Multiply that by fifty saves an hour, every day, across a whole team, and "slightly slow" becomes a real productivity tax.
The frustrating part is that slow local development rarely has one obvious cause. It creeps up as your app grows, one new dependency and one new route at a time, until the dev server that felt instant on day one feels sluggish by month six. This guide walks through why that happens, what next dev is actually doing differently from a production build, and the concrete levers you have to pull to get your feedback loop back.
Local dev is not a slow production build, it is a different job entirely
It's tempting to think of next dev as "the same thing next build does, just less optimized." That's not quite right, and understanding the difference matters because it tells you where to actually look when things get slow.
next build compiles every route in your application up front. It minifies output, generates content hashes for cache busting, tree-shakes aggressively, and produces a fully optimized production artifact. All of that work happens once, and none of it needs to happen again until your next deploy.
next dev, on the other hand, is lazy by design. It doesn't compile your entire app before you can start working. Instead, it compiles a route the moment you navigate to it (or, for a Server Component, the moment a request needs it), and it keeps that compiled module in memory so subsequent visits are fast. This on-demand compilation is precisely what lets you run next dev on a project with hundreds of routes without waiting minutes for the server to "warm up."
The tradeoff is that this on-demand model exposes different bottlenecks than a production build does. A build is bottlenecked by total compile time across the whole app. Dev is bottlenecked by the compile time of whatever you just touched, plus how efficiently the file-watcher, the module graph, and Fast Refresh cooperate to get your one changed file back on screen. That's why optimizing production build speed and optimizing dev server responsiveness are genuinely different exercises, even though they both start with the same next.config.js.
With that framing in mind, here's where the actual slowdowns tend to hide, roughly in the order I'd check them.
Rule out your antivirus software first
This sounds like a strange thing to lead with, but it's one of the highest-leverage fixes because it's invisible until you go looking for it, and because it silently taxes every file operation Next.js performs, not just compilation.
Antivirus software works by intercepting file system calls to scan them before they're allowed to complete. A dev server does an enormous number of file reads and writes in a short span — watching for changes, reading source files, writing to .next/, resolving node_modules. If every one of those operations is passing through a scanning layer, you're paying a tax on every single file touch, and that tax compounds as your project grows.
This is a much bigger problem on Windows than macOS or Linux, largely because Windows Defender is on by default and actively scans project directories unless told otherwise. If you're on Windows:
- Open Windows Security → Virus & threat protection → Manage settings → Add or remove exclusions.
- Add a Folder exclusion pointing at your project directory (and ideally your global
node_modulescache location too, if you use one).
On macOS, the equivalent friction usually comes from Gatekeeper's on-execution checks rather than a background scanner, but it's worth ruling out:
sudo spctl developer-mode enable-terminal
Then open System Settings → Privacy & Security → Developer Tools, confirm your terminal app is listed and enabled (if you use iTerm2, Ghostty, Warp, or anything other than Terminal.app, add it explicitly), and restart your terminal for the change to take effect.
If your organization manages your machine through an MDM policy, there may be a third-party antivirus product layered on top of the OS defaults too — CrowdStrike, SentinelOne, and similar endpoint tools are common culprits in corporate environments, and they usually have their own exclusion list separate from Windows Defender's.
Practical note the docs don't mention: if you're not sure whether antivirus scanning is actually your bottleneck, a quick way to check is to compare next dev startup and rebuild times with the antivirus's real-time protection temporarily disabled (if your IT policy allows it). A dramatic difference confirms the theory; if nothing changes, move on to the next section without wasting more time here.
Make sure you're actually running Turbopack
If you started your project more than a year or two ago, there's a real chance you're still building with webpack out of inertia, not because you chose it. Turbopack has been the default bundler for next dev for a while now, and it is dramatically faster for incremental compilation, which is exactly the workload local development stresses.
The fix is almost embarrassingly simple: update, and don't opt back into webpack unless you have a specific reason to.
# npm
npm install next@latest
npm run dev # Turbopack is used automatically
# pnpm
pnpm add next@latest
pnpm dev
# yarn
yarn add next@latest
yarn dev
# bun
bun add next@latest
bun dev
If you (or a plugin, or a legacy config file) are explicitly forcing webpack, you'll see it via the --webpack flag:
next dev --webpack
Removing that flag is often the single biggest local-dev speedup available to a team, because it costs nothing and requires no code changes. The only reason to keep webpack around in development is if you depend on a webpack-only loader or plugin that doesn't have a Turbopack equivalent yet — and even then, it's worth checking whether that dependency has shipped Turbopack support recently, since the ecosystem has been catching up quickly.
A gotcha worth knowing: if your team has custom webpack() config in next.config.js for a legitimate reason (a niche loader, a monorepo alias hack), Turbopack won't silently apply that config — it has its own configuration surface. Don't assume switching bundlers is a no-op if your webpack config does anything beyond the basics; test the switch on a branch first.
Audit what you're importing, not just what you're using
This is the one most teams underestimate, because the code looks fine. You wrote import { Icon } from 'some-library', used exactly one icon, and moved on. The problem is what happens underneath that import statement.
Icon libraries are a classic offender
Packages like react-icons, @phosphor-icons/react, and @material-ui/icons bundle thousands of icon components behind a single package entry point. Depending on how the package is structured, importing one named export can still force the compiler to parse and resolve the entire module graph behind it.
Prefer deep, specific imports when the library documents a path for them:
// Forces the compiler to resolve the whole icon set
import { TriangleIcon } from "@phosphor-icons/react";
// Resolves only the one module you actually need
import { TriangleIcon } from "@phosphor-icons/react/dist/csr/Triangle";
If you're using react-icons, the bigger risk isn't one icon — it's mixing icon sets. Each prefix (pi for Phosphor, md for Material Design, tb for Tabler, cg for css.gg, and so on) is its own large module tree. A codebase that imports from four or five different sets across different components, even just one icon from each, forces the compiler to process tens of thousands of modules in aggregate. Pick one icon set for your project and enforce it with a lint rule if you can — the compile-time cost of "just this once" icon from a second set adds up faster than it looks.
Barrel files quietly tax every build
A "barrel file" is an index.ts that re-exports everything from a directory, so consumers can write import { Button, Card, Modal } from '@/components' instead of three separate import lines. It's convenient to write, but expensive to compile: the bundler has to parse the entire barrel file and follow every re-export to determine whether any of them has module-level side effects, even if you only imported one thing from it.
Where possible, import directly from the specific file:
// Cheap to resolve
import { Button } from "@/components/Button";
// Forces the compiler through the whole barrel
import { Button } from "@/components";
I'm not going to pretend this is always practical — plenty of teams have barrel files baked deep into their conventions, and rewriting every import across a large codebase isn't a reasonable ask just to shave dev-server milliseconds. The pragmatic middle ground is Next.js's built-in optimizePackageImports option, which automatically rewrites barrel-style imports for you at compile time for packages you list:
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ["package-name"],
},
};
One detail worth flagging: this setting only matters if you're on webpack. Turbopack analyzes and optimizes these imports automatically, so if you followed the advice above and switched to Turbopack, this configuration becomes a no-op you can safely leave in place (or remove) without needing to think about it further.
If you use Tailwind, check your content globs
This one is sneaky because a misconfigured content array doesn't produce an error — it just quietly makes every build slower, and the slowdown scales with how much unrelated code happens to live in your repository.
The mistake is writing a content glob that's broader than it looks:
// tailwind.config.js
module.exports = {
content: [
// Looks scoped, but in a monorepo this can match
// packages/**/node_modules too
"../../packages/**/*.{js,ts,jsx,tsx}",
],
};
Tailwind has to scan every file matched by these globs on every build to determine which classes are actually used, and an accidental match into a node_modules directory can mean scanning tens of thousands of files that have nothing to do with your styles. Scope your globs as tightly as you actually can:
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/ui/src/**/*.{js,ts,jsx,tsx}",
],
};
Tailwind CSS 3.4.8 and newer will actually warn you in the terminal when your config looks like it might be scanning more than it should — if you see that warning, take it seriously rather than dismissing it, since it's specifically flagging the exact problem described here.
Question your custom webpack config
If your next.config.js has a webpack() function doing anything nontrivial — custom loaders, aliases, plugins — ask yourself honestly whether all of that needs to run during local development, or whether it exists purely to support something production-specific like bundle analysis or a build-time asset pipeline.
A common pattern that helps: gate expensive webpack customizations behind a check for the current phase, so they only apply during next build:
// next.config.js
module.exports = {
webpack: (config, { dev }) => {
if (!dev) {
// production-only customizations
}
return config;
},
};
And if you haven't already made the Turbopack switch discussed earlier, this is another reason to: Turbopack's loader configuration surface is different, and for teams that migrate, a lot of "we need custom webpack config" turns out to have been solving a problem Turbopack handles natively.
Memory pressure is a real, separate problem
If your application is large — a big monorepo, hundreds of routes, a sprawling component library — the dev server may simply need more memory than it's been given, and once it starts swapping or garbage-collecting aggressively, everything gets slower in a way that looks like a compilation problem but isn't. This deserves its own investigation rather than a quick fix here; Next.js has a dedicated guide to memory usage that covers heap snapshots, increasing Node's memory limit, and diagnosing leaks specifically.
Server Components re-fetch on every edit — that's expected, but you can cache it
Here's a piece of behavior that surprises people the first time they notice it: editing a Server Component causes Next.js to re-render the entire page locally to reflect your change, and that re-render includes re-running any data fetching that component does. If your Server Component calls a slow API or a rate-limited third-party service, every single save during development triggers a fresh network request.
This isn't a bug, it's Fast Refresh doing its job correctly — showing you the true current state of the page, not a stale one. But it does mean that if your data source is slow or costs money per call, your iteration speed is bounded by that request's latency, not by Next.js's compile time at all.
The experimental serverComponentsHmrCache option addresses exactly this. It caches fetch responses in Server Components across Hot Module Replacement refreshes, so editing an unrelated part of the component doesn't force the fetch to re-run:
// next.config.js
module.exports = {
experimental: {
serverComponentsHmrCache: true,
},
};
Worth knowing: this cache is scoped to development and HMR specifically — it has nothing to do with your production caching strategy, and enabling it won't change what gets cached when you deploy. It exists purely to stop your dev server from re-hitting a live API every time you tweak a className.
If you develop inside Docker, this is probably your real problem
I've saved this one for last because, in my experience, it's the single biggest and most under-diagnosed source of "Next.js dev is just slow for us" complaints — and it has nothing to do with Next.js at all.
If you're running your dev environment inside a Docker container on macOS or Windows, file system operations that cross the boundary between the container's Linux filesystem and the host OS go through a virtualization layer, and that layer is slow. Hot Module Replacement depends on the dev server noticing a file changed as fast as possible; when file-watching has to traverse that virtualized boundary, watch events that would be near-instant on native Linux can take seconds, and in bad cases, minutes.
This isn't a Next.js-specific quirk — any file-watching dev server suffers the same problem inside Docker Desktop on Mac or Windows. The practical guidance is blunt:
- Run your actual development loop (
npm run dev/pnpm dev) directly on your host machine, not inside a container. - Reserve Docker for what it's actually good at in this context: producing and testing production builds, and matching your deployment environment for final verification.
- If your team's tooling genuinely requires Docker during development (a shared service mesh, specific system dependencies), run it on a native Linux host or VM rather than Docker Desktop on macOS/Windows — the filesystem penalty specifically comes from the cross-OS virtualization layer, not from containers in general.
If you're on Windows and want container-like isolation without this penalty, WSL2 with your project files living inside the Linux filesystem (not on a mounted Windows drive) sidesteps the same problem, since the whole toolchain then runs on a native-feeling Linux filesystem end to end.
When you need to actually measure, not guess
Everything above is "check the usual suspects." Sooner or later you'll hit a slowdown none of them explain, and at that point, guessing wastes more time than measuring.
Turn on detailed fetch logging
If you suspect data fetching, not compilation, is behind your slow refreshes, the logging.fetches option gives you a clear, timestamped view of every fetch Next.js makes during development, right in your terminal:
// next.config.js
module.exports = {
logging: {
fetches: {
fullUrl: true,
},
},
};
This alone is often enough to confirm or rule out the "slow API + serverComponentsHmrCache would help here" theory from earlier, without needing heavier tooling.
Generate and read a Turbopack trace
For genuinely mysterious compile-time slowness, Turbopack ships its own tracing tool that records exactly how long each module took to compile and how modules relate to each other — this is the closest thing to a compiler profiler you'll get, and it's worth reaching for before you start guessing at which dependency is the culprit.
next dev --internal-trace
Reproduce the slow behavior — navigate around, make the edit that feels sluggish — then stop the dev server. You'll find a trace-turbopack.bin file in the .next-profiles directory. Interpret it with:
npx next internal trace .next-profiles/trace-turbopack.bin
(On older Next.js versions, this same command was named turbo-trace-server instead of trace — if the command above errors as unrecognized, try that name.)
This starts a local trace server; open the link it prints to view the trace in your browser at trace.nextjs.org. By default the viewer aggregates timings across repeated operations, which is useful for spotting a slow module overall, but if you're chasing a single slow edit, switch the view from "Aggregated in order" to "Spans in order" in the top right — that gives you the literal sequence of what happened, in order, which is usually more useful for a one-off repro than an aggregate.
Practical note: don't reach for tracing as your first step. It's the right tool once you've ruled out the cheaper checks above (antivirus, bundler choice, imports, Tailwind globs, Docker) — reaching for a profiler before checking whether you're still on webpack by accident is a common way to spend an afternoon debugging a problem that a five-minute upgrade would have fixed.
Still stuck? Share the trace, don't just describe the symptom
If none of the above resolves it, the most useful thing you can bring to a GitHub Discussion or the Next.js Discord isn't a description of "dev feels slow" — it's the trace file itself. A trace makes the problem reproducible and inspectable for whoever's helping you, instead of asking them to guess based on a vibe.
Key Takeaways
| Symptom | Likely cause | Fix |
|---|---|---|
| Everything is slow, even simple edits | Antivirus scanning file operations | Add project directory to exclusion list |
| Dev noticeably slower than it "should" be | Still on webpack instead of Turbopack | Update Next.js, drop the --webpack flag |
| Slow to compile pages with lots of icons | Barrel-style icon imports pulling in whole sets | Use deep imports; stick to one icon set |
| Slow builds despite small changes | Barrel files or overly broad Tailwind content globs | Import directly from source files; scope Tailwind globs tightly |
| Refresh triggers a slow network call every time | Server Component re-fetching on every edit | Enable serverComponentsHmrCache |
| HMR takes seconds to minutes | Developing inside Docker on macOS/Windows | Run next dev natively, or use WSL2/native Linux |
| Large app feels sluggish overall | Insufficient memory for the dev process | See the memory usage guide |
None of these fixes require rewriting your application. They're closer to routine maintenance — the kind of thing worth revisiting every few months as your app grows, rather than a one-time setup task. The return on a fast dev server compounds every single day your team works in the codebase, which makes this one of the higher-leverage places to spend an afternoon.


