SWC vs Babel is the question every webpack project eventually asks when the build starts to hurt, because the toolchain you pick sets what each file costs. SWC is a Rust compiler that parses and rewrites JavaScript and TypeScript natively. Babel is a JavaScript compiler built around plugins, and it runs on the same single thread as your build. I wanted numbers instead of folklore, so I generated a repo, ran both, and wrote down what happened. Short version: the raw transform gap is huge, the whole-build gap is modest, and one comparison I was asked to include, Turbopack against webpack, I did not run.
What exactly did I measure?
There are two runs in this article, and I'll label them every time. The first, the "container" run, happened in one session on a 4-vCPU Intel Xeon at 2.8 GHz with 15 GB of RAM, a shared virtual machine, under Node 22.22.0. The packages were whatever npm install resolved on the day:
@swc/core1.16.18@babel/core8.0.7 with@babel/preset-env8.0.7,@babel/preset-react8.0.1 and@babel/preset-typescript8.0.7esbuild0.28.2webpack5.111.1 withbabel-loader10.1.1 andswc-loader0.2.7reactandreact-dom19.3.1
The input was 400 generated .tsx files, each about 950 bytes: a typed props interface, useState and useMemo, an async function, a mapped list with optional chaining and an onClick handler. Nothing exotic. They look like the boring middle of a real component library, which is exactly why I picked them.
The script from the container run never made it into the repository, which is a real weakness for a benchmark post. So before this article went live I rebuilt it from the description above. It sits in examples/swc-vs-babel-webpack-benchmark/ with the blog's other code examples: pinned versions, a lockfile, a generator for the 400 files (943 bytes on average), and one command, node bench.mjs, that prints the tables below for whatever machine runs it. Because it's a rebuild, it matches the container run in shape and not byte for byte. I ran it on an Apple M1 with 8 cores and 16 GB, under Node 22.23.3 because 22.22.0 wasn't installed on that machine, with 5 fresh processes per transformer and 9 webpack builds per loader. Those are the "M1" numbers. The Mac is a workstation that was busy with other work (load average 6 to 10 on 8 cores), so read them as a loaded-laptop result.
The harness runs each transformer over all 400 files, six rounds in one process. Round one is the cold run, rounds two to six are warm, and I report the median of the warm rounds.
const t = [];
for (let i = 0; i < 6; i++) {
const s = performance.now();
run();
t.push(performance.now() - s);
}
const warm = t.slice(1).sort((a, b) => a - b);
console.log("cold", t[0], "warm median", warm[2]);
In the container I repeated the whole thing three times per tool. It was otherwise idle, but it is a shared virtual machine, so treat a 10 percent difference as noise.
How big is the raw transform gap?
Bigger than any blog post's headline number, honestly. Here are the warm medians from the container's three repetitions, next to the M1 harness run.
| Transformer | Container, warm median | Container, cold first round | M1, warm median | M1, cold first round |
|---|---|---|---|---|
| @swc/core 1.16.18 (TSX, automatic runtime, es2022) | 70 to 78 ms | 76 to 90 ms | 18 ms | 20 to 30 ms |
esbuild 0.28.2 transformSync | 564 to 683 ms | 616 to 822 ms | 136 ms | 170 to 722 ms |
| Babel 8.0.7, React + TypeScript presets only | 1318 to 1358 ms | 2.0 to 2.3 s | 262 ms | 433 to 487 ms |
| Babel 8.0.7, with preset-env (node 22 targets) | 2458 to 2733 ms | 3.1 to 3.5 s | 280 ms | 504 to 595 ms |
Three things jump out. First, SWC's cold run is nearly its warm run on both machines, while Babel pays a warm-up on the first pass: roughly 0.7 seconds in the container, about 0.2 to 0.3 seconds on the M1. Second, the M1 is about 4x to 5x faster on every row except preset-env, which is the CPU and nothing clever. Third, the esbuild row will look wrong to anyone who has used esbuild. Its native speed is not the issue. I called transformSync once per file, and each call crosses a process boundary. Batch the work through its build API and the picture changes, but that's a different test, and I didn't run it. Don't read that row as "esbuild is slower than SWC". It was 7x to 9x behind SWC in both runs.
Is the comparison fair?
Not entirely, and I'd rather say so. The SWC run only strips types, compiles JSX and targets ES2022. The first Babel run also applies preset-env, which does more. That's why I added the second Babel row without it. Even that stripped-down Babel configuration took about 17 times longer than SWC in the container and 14 times longer on the M1, so the gap isn't just preset-env.
The preset-env row is where the two runs disagree, and by more than noise. In the container it roughly doubled Babel's time. On the M1 it added about 7 percent, and the total output was exactly the same size with and without it (349,560 characters for 400 files), which tells me targets: { node: "22" } left nothing for preset-env to transform. I don't know what differed in the container run, and the original script isn't around to check. So the 35x figure in the container is one machine's result with one setting I can't reconstruct. The 14x to 17x figure for Babel without preset-env held on both machines, and that's the one I'd repeat.
Without preset-env, the raw transform gap between SWC and Babel was 14x to 17x on both machines I tried. The webpack build gap was 1.6x to 1.8x. Both numbers are true, and only one of them is your CI time.
What happens inside a real webpack build?
This is where the headline number deflates. I wired the same 400 files into a webpack 5 production build, once with babel-loader and once with swc-loader, same entry file, cache: false, React marked external. In the container: three runs each, wall clock, including npx startup.
| Container run | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
| babel-loader (React, TypeScript, preset-env to Chrome 120) | 6512 ms | 6103 ms | 5792 ms |
| swc-loader (TSX, automatic runtime, es2022) | 3351 ms | 3439 ms | 3143 ms |
So the swap saved about 3 seconds on a 6-second build. About 1.8x faster, not 30x. The rest is webpack itself: resolving 401 modules, building the graph, running the minifier and writing the bundle. None of that gets faster because the compiler is written in Rust.
The M1 harness run used the same loader options, with nine builds per loader started as node webpack-cli processes, so the figures include Node and webpack startup but not npx:
| M1 run | Median | Min to max, 9 builds |
|---|---|---|
| babel-loader 10.1.1 | 1868 ms | 1758 to 1998 ms |
| swc-loader 0.2.7 | 1166 ms | 1073 to 1298 ms |
That's 1.60x, and the swap saved 0.7 seconds. Two more M1 runs gave 1.59x, and 1.62x when timed through npx like the container run. So the ratio travelled between a Xeon container and a Mac: 1.6x to 1.8x. The seconds didn't. A 3-second saving became a 0.7-second one, which matters for the 4,000-file arithmetic further down.
One honest flaw. My entry file only imports the components and exports an array, and the production build tree-shook nearly all of it, so the container's output bundles were 217 bytes for Babel and 33 bytes for SWC. The harness bundles came out at 73 and 69 bytes, a difference I put down to how React is kept external there, though I can't prove that against the lost original. The loaders still had to transform every file, which is what I timed, but the minifier had almost nothing to chew on. A real app that keeps its code would spend more time in minification, and the loader share of the total would shrink even further.
What does the swap look like in config?
Small. The swc-loader rule from my test is essentially this:
{
test: /\.tsx$/,
exclude: /node_modules/,
use: {
loader: "swc-loader",
options: {
jsc: {
parser: { syntax: "typescript", tsx: true },
transform: { react: { runtime: "automatic" } },
target: "es2022",
},
},
},
}
The package docs for swc-loader cover the remaining options. The Babel version needs babel-loader plus three presets, which is more to install but also more to configure per target.
What breaks when you drop Babel?
Plugins. Plain TypeScript and JSX behave the same under both compilers in my test, and SWC emitted the expected react/jsx-runtime import for the automatic runtime. But Babel's value was never just compiling syntax. It is the plugin ecosystem. If your build runs the React Compiler as a Babel plugin, for example, SWC cannot stand in for it automatically. My React Compiler guide covers what that plugin needs from the build, and it's worth reading before you rip anything out.
Before you swap, check these:
- Every entry in
babel.config.*, including macros and custom plugins - Whether
preset-envtargets differ from what you set onjsc.target - Whether any tool in your pipeline calls Babel directly, such as a Storybook or Jest preset
- Whether type checking still runs somewhere, because neither compiler checks types
That last point matters more than it sounds. SWC and Babel both delete annotations without validating them, so tsc --noEmit stays in CI. If you've moved to the native compiler, the TypeScript 7 bundler compatibility notes explain which loaders touch the compiler API and which never do.
What about Turbopack against webpack?
I didn't test it. The request behind this article included that comparison, and I'd rather leave a gap than invent a figure. A fair Turbopack run needs a real Next.js app built under both bundlers, with identical routes and identical dependencies, and I did not build one. The Next.js Turbopack reference is the place to check current status and supported features. If you see a Turbopack multiplier quoted anywhere without a repo, a machine and a version, treat it the way you'd treat mine: as one machine's result.
So which one should you use?
My judgement call: if you start a project today and your build is webpack, use swc-loader and keep Babel for the plugins that need it. If you are on Vite, you already skip this choice. The transform step was never the bottleneck I'd worry about first, and a 3-second saving on 400 files (0.7 seconds on the M1) is nice rather than life-changing. If the saving scaled linearly to a 4,000-file app, which I haven't tested, it would be about 30 seconds in the container and about 7 on the M1. That's roughly where it starts to pay for the migration afternoon, and it's why the machine matters more than the ratio.
Run your own numbers before deciding. The harness in examples/swc-vs-babel-webpack-benchmark/ does it with npm install and node bench.mjs, and --files changes the project size. A result from your machine beats any table on a blog, mine included. If your tests also run through Babel, the Jest to Vitest migration is the other half of the same cleanup.