Webpack Is Not the Problem

Webpack is not the problem. Not understanding it is.

I spent a long time quietly nodding along when people called webpack a nightmare. Then one bad week forced me to actually read the internals, and most of what I hated turned out to be stuff I had simply never learned. This is me trying to save you that week.

Every few months something new shows up promising to free us from webpack. And every time, I notice the same thing: the loudest voices are usually the ones who never learned the tool they are running from.

I am not here to tell you webpack is perfect. I am here to tell you that "this config confuses me" and "this tool is bad" are very different statements, and we keep mixing them up.

The Complaints Are Real, the Conclusion Is Wrong

People complain about webpack constantly, and honestly, a lot of the complaints are fair. Config sprawl. Slow cold starts. The 400-line webpack.config.js that nobody on the team fully understands. I have written that file. I have also been a little scared of that file.

But look at what the alternatives are actually selling.

Vite gives you a fast dev server, then quietly runs a different production pipeline underneath. Esbuild gives you raw speed and hands most of the hard decisions back to you. Turbopack is the real exception here, and it earns its own section near the end.

A lot of the time you are not removing complexity. You are moving it somewhere you cannot see it. And hidden complexity has a way of showing up at the worst possible moment, usually in production, usually with no useful error message.

That trade has never felt worth it to me.

The Module Graph

Let me start at the bottom, because everything else sits on top of this.

Webpack's whole job begins with building a dependency graph. It walks your imports through the NormalModuleFactory, parses each file into an AST with Acorn, and creates Dependency objects for every import it finds.

When Acorn hits an import statement, webpack records it as something like a HarmonyImportSideEffectDependency and a HarmonyImportSpecifierDependency. These little objects are the connective tissue. They hold the metadata that wires one file to another.

The part I find genuinely elegant is that in webpack 5 the ModuleGraph is decoupled from the ChunkGraph. One answers "what does this module need." The other answers "how do we package it." Keeping those two questions apart is a clean boundary, and it is the thing that makes everything downstream possible.

Module Dependency Graph

Hover a module to trace what it imports.

importcircular
app.jsHeader.jsstyles.cssapi.jsauth.jsutils.js

Each node is a module. Each arrow is an import. Together they form the graph webpack walks before it bundles anything.

Circular dependencies are where this design quietly shows off. Webpack caches a module's exports object before it runs the module body. So if A imports B and B imports A, the second require gets the half-finished cached object instead of spinning into an infinite loop. As long as you are not synchronously reaching into those exports during evaluation, the cycle just resolves.

This graph is the load-bearing wall. If you do not have a mental model of it, you do not really have a mental model of your app.

The Loader Pipeline

Loaders confused me for an embarrassingly long time, so let me explain them the way I wish someone had explained them to me.

A loader is just a function. It takes a string or a buffer and returns JavaScript. That is it. The only twist is that the pipeline runs in two passes.

The pitch phase runs left to right. The normal phase runs right to left. So for [style-loader, css-loader], css-loader actually runs first in the normal phase, and its output gets handed up to style-loader.

The pitch mechanism is the clever bit. If a loader's pitch function returns a value, webpack stops walking right, turns around early, and runs the normal phase of everything to its left.

module.exports.pitch = function (remainingRequest) {
  return (
    "module.exports = require(" +
    JSON.stringify("-!" + remainingRequest) +
    ");"
  );
};

style-loader uses exactly this trick. It pitches a tiny module that requires the css-loader output inline, then injects the styles into the DOM at runtime. It never has to touch the raw CSS string itself.

The Loader Pipeline

Pitch runs left to right, then normal runs right to left.

pitch →← normal
style-loader
injects the CSS into the DOM
css-loader
resolves @import and url()
less-loader
compiles Less into CSS
styles.less
the source file on disk
Run the chain to watch webpack walk the loaders in two passes.

And here is where a lot of the "webpack is slow" reputation actually comes from. Most of the slow builds I have seen were self-inflicted: a dozen loaders chained to do AST work on a single thread, or Babel grinding through all of node_modules because nobody set up an exclude. That is not webpack being slow. That is us handing it an impossible amount of work and blaming the messenger.

The Plugin Architecture (Tapable)

If the module graph is the skeleton, Tapable is the nervous system.

Tapable is webpack's own event system, built for performance and for fairly complex control flow. You get different hook types for different needs. SyncHook runs taps in order. SyncBailHook stops the moment a tap returns something defined. AsyncSeriesHook runs async taps one at a time, and AsyncParallelHook runs them together.

const { SyncHook } = require("tapable");

class Compiler {
  constructor() {
    this.hooks = {
      compile: new SyncHook(["params"]),
    };
  }
}

The detail I think is genuinely cool: Tapable compiles your registered callbacks into a single function at runtime using new Function(). No iterating over an array of listeners on every call. From what I understand, this also gives V8 a tight, predictable path to optimize.

What clicked for me eventually is that basically every part of webpack is itself a plugin tapping into these hooks. The compiler, the compilation, the parser, the resolver. They are all decoupled, and they all talk through Tapable.

That is why webpack's plugin ecosystem runs so deep. Most tools hand you a handful of lifecycle callbacks. Webpack hands you the whole engine and lets you reach inside it.

Chunking and Code Splitting

Modules go in. Chunks come out. The ChunkGraph is the map between the two.

There are three kinds of chunks worth knowing. Initial chunks come from your entry points. Async chunks come from dynamic import(). Split chunks come from the SplitChunksPlugin.

SplitChunksPlugin walks the graph looking for modules shared across multiple chunks and, based on your config (minimum size, max parallel requests, and so on), pulls them out into chunks of their own.

Code Splitting & Chunking

1 of 4: Everything bundled into a single main.js.

main.js295kb
webpack/runtime2kb
node_modules/react120kb
node_modules/lodash70kb
src/index.js5kb
src/App.js15kb
src/utils.js8kb
src/Dashboard.js45kb
src/Settings.js30kb
Why it matters: Change one line of app code and the browser re-downloads all of it, including 190kb of libraries that never changed.

One thing worth doing on purpose: extract the runtime into its own chunk.

optimization: {
  runtimeChunk: "single",
  splitChunks: {
    chunks: "all",
  },
}

Without that, the runtime manifest lives inside your entry chunk. So changing one tiny async module rewrites the manifest, which changes the entry chunk's hash, which quietly breaks long-term caching for everyone who already had your site loaded. Pulling the runtime out keeps that churn contained.

This is the part I actually enjoy. You are deciding the physical shape of what ships to the browser.

Module Federation

Module Federation is not really a separate tool. It is webpack bending its own module resolution and runtime so that multiple independent builds can behave like one app.

ContainerPlugin exposes modules. ContainerReferencePlugin consumes them. A remote entry is a small runtime container that maps module names to async getters.

When a host pulls in a remote module, ContainerReferencePlugin intercepts the import. Instead of looking in the local graph, it injects runtime code that fetches the remote entry, asks it for the getter, and resolves the module asynchronously.

SharePlugin handles the scary part: shared dependencies. It sets up a global scope where host and remotes register their versions and negotiate the highest compatible one, so you do not end up with three copies of React fighting each other.

As far as I can tell, this is still the only micro-frontend approach that solves the problem at runtime instead of papering over it at the edges. The alternatives mostly do not try.

Tree Shaking

Tree shaking happens in two moves: mark, then sweep.

FlagDependencyUsagePlugin walks the graph and marks which exports are actually used. This only works because ES modules are static, so the imports and exports can be analyzed without running the code.

Setting "sideEffects": false in package.json is the underrated half of this. It tells webpack that if a module's exports go unused, the whole module can be dropped without even being parsed. Import one function from a fifty-function utility library and you really do not want the other forty-nine riding along.

There is a real difference from Rollup here. Rollup scope-hoists by default, flattening modules into one closure. Webpack keeps modules in their own closures and leans on a minifier like Terser for the final dead code elimination.

You can opt into Rollup-style hoisting with the ModuleConcatenationPlugin, but it bails out on some dynamic cases. Webpack tends to choose correct execution over a slightly smaller bundle, and I think that is the right call.

The Runtime

__webpack_require__ is the little function the whole thing comes down to. Webpack keeps a module cache and checks it first.

function __webpack_require__(moduleId) {
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (__webpack_module_cache__[moduleId] = {
    exports: {},
  });
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  return module.exports;
}

Cache hit, return the exports. Cache miss, run the module body, store the result, return it. That cache, by the way, is exactly the trick that makes circular dependencies safe.

Lazy loading runs through __webpack_require__.e. It creates a Promise and injects a <script> tag for the async chunk. The chunk is wrapped in a JSONP-style callback that pushes its modules into the registry and resolves the Promise once it lands.

Reading the generated runtime once did more for my debugging than any blog post. It is just the graph made executable, and once you can read it, your production stack traces stop feeling like noise.

Persistent Caching

Webpack 5 shipped a real filesystem cache, and it is a big reason rebuilds feel fast now.

Instead of re-hashing the world on every build, the PackFileCacheStrategy snapshots filesystem metadata like timestamps and content hashes. Webpack even wrote its own serialization engine to flatten the module graph, circular references and all, into a binary format on disk.

It runs in two tiers: a memory cache for instant rebuilds while you work, and a disk cache that survives restarts, with the memory cache flushing out during idle time.

Cache invalidation is the genuinely hard problem, and webpack handles it through build dependencies. Touch your config or bump a loader, and it notices the hash change and throws the cache away. Slower that one time, correct every time. I will take that trade.

Rspack Is the Tell

Here is the thing that made all of this click for me.

Rspack is not really a webpack competitor. It is webpack rebuilt in Rust. Same API, same loader concepts, same plugin architecture.

The people who built it looked at the whole ecosystem, decided webpack's mental model was already right, and concluded the only real problem was JavaScript's single-threaded performance. So they kept the model and changed the material.

That is about the strongest endorsement webpack could ask for. When the fast rewrite copies your design instead of replacing it, the design was never the thing that was broken.

Turbopack Changes the Engine, Not Just the Material

I should be honest about how I actually ended up caring about any of this. For a few months, the worst part of working in a large Next.js app was the dev server. I would save a file, tab over to the browser, and wait long enough to lose my train of thought, and it kept getting slower as the app grew, which is exactly backwards from how it should feel.

Turning on Turbopack was the first time dev mode stopped fighting me. Recompiles went from "go refill my water" to basically instant. That jump is the whole reason I went looking for what it was doing differently, and the answer turned out to be more than just Rust.

Rspack kept webpack's architecture and swapped the language. Turbopack does the more interesting thing. It keeps the mental model and rethinks the engine underneath it.

The detail that sold me is who built it. Tobias Koppers wrote webpack. Then he went to Vercel and started over with Turbopack. So this is not a competitor taking shots from the outside, it is the same person, a decade of lessons later, deciding what he would do differently with a clean slate.

And the thing he changed is exactly the hard part from the last section: caching.

Remember how webpack's incremental story works. It rebuilds, then leans on a cache to skip work it has already done. That is a real improvement, but as the app grows, the overhead of walking the graph and asking "have I done this already" on every change becomes its own tax. You are still starting from the top every time.

Turbopack flips the direction. Its core is an incremental computation engine called Turbo Tasks, and the idea is closer to a spreadsheet than a build tool. Every small unit of work, parsing a file, resolving an import, extracting its dependencies, is a memoized function, and when one of those functions reads a value, the engine quietly records that read as a dependency.

If you have used signals in something like SolidJS, that tracking will feel familiar. It calls the tracked values "cells," and it builds the dependency graph by watching which cells each function actually reads.

So when a file changes, Turbopack does not restart from the top. It marks the exact functions that read that file as dirty and recomputes only those, bubbling the change upward until the graph is consistent again. Nothing else runs. You are not invalidating a file so much as invalidating a handful of functions inside it.

That granularity is genuinely wild. A single module might be a hundred of these tiny cached functions, one for parsing, one for resolution, one per dependency. Koppers has described it as almost token-level, and the result is that "what changed" usually maps to a sliver of the graph instead of a rebuild.

The other shift is laziness, and it is the one I felt most directly. Webpack's dev server wants to understand your whole application before it serves the first page. Turbopack compiles on request, building only what the browser actually asks for. That is why its startup stays roughly flat whether your app is small or enormous, and why the pain I had been complaining about basically went away.

This is the part that makes it a real upgrade and not a reskin. Rspack made webpack faster. Turbopack changed what "rebuild" even means.

It is not free, and I would be lying if I said otherwise. Loaders mostly carry over, but webpack plugins do not, so a lot of that deep plugin ecosystem does not come with you yet. As of Next.js 16 it is the default bundler for both dev and build, and if you are holding a custom webpack config, the build will tell you about it loudly. Some of this is still settling.

But here is what I keep landing on. Open Turbopack up and it still has a module graph. It still resolves modules, splits chunks, and ships a runtime. The person who built webpack built it too, and conceptually it still looks like webpack.

The engine is new. The mental model is the one you already learned.

So, Should You Actually Learn It?

I think so, yeah.

Not because webpack is sacred, and not because the newer tools are bad. Some of them are great, and I reach for them too. But the thing you learn while reading webpack is not really "webpack." It is how modern JavaScript gets from a folder of files to something a browser can run: the dependency graph, module resolution, chunking, the runtime.

That knowledge follows you to every other bundler, because they are all solving the same problems. Vite, Rspack, Turbopack, esbuild, whatever comes next. They all have a module graph and a runtime hiding somewhere inside them.

So before you swap tools again, maybe just open the engine first. Worst case, you finally understand your build. Best case, you stop needing to run from it.