Transpilation
Date: 2026-08-17
Converting source into equivalent code an older target can run. The compatibility target is the single biggest lever on output size that most teams never touch — and the default is usually far more conservative than their actual visitors need.
Transpilation converts source from one form to another at the same level of abstraction — modern JavaScript to older JavaScript, TypeScript to JavaScript, JSX to function calls.
What gets transformed
TYPE STRIPPING TS → JS
→ types erased, nothing
checked
← fast, and NOT type
checking
SYNTAX DOWNLEVEL ?? ?. async class fields
→ equivalent older syntax
JSX <div/> → jsx('div')
POLYFILLS Array.at, Object.hasOwn
→ runtime additions,
NOT syntax
Syntax and polyfills are different problems. Transpilation rewrites syntax; polyfills add missing runtime functions. A build can handle one and not the other. Both differ again from a shim, which wraps an API that already exists — Shims and Polyfills.
The tools
| Written in | Type-checks | Speed | |
|---|---|---|---|
| Babel | JS | No | Slowest, most configurable |
| SWC | Rust | No | Very fast |
| esbuild | Go | No | Very fast |
| tsc | TS | Yes | Slow |
None of the fast ones type-check. They strip types and move on, which is why type checking has to run separately — Type Checking in CI.
vite build # strips types, no checking
tsc --noEmit # checks, emits nothingBoth are needed, and a build that only does the first will happily ship type errors.
The target is the lever
How far down you transpile determines how much code you ship.
target: es5
→ async/await becomes a state machine
→ classes become functions and
prototypes
→ generators need a large runtime
→ SUBSTANTIALLY more output
target: es2020
→ most modern syntax passes through
untouched
→ much smaller, and faster to parse
Every browser in meaningful use supports ES2020. Targeting ES5 in 2026 ships transformation for browsers nobody uses — and the cost is paid by every visitor on every page.
Check your actual analytics before choosing. The default in an inherited configuration is frequently a decision made years ago for a browser matrix that no longer applies.
Browserslist
The shared configuration most tools read:
# .browserslistrc
> 0.5%
last 2 versions
not dead
One declaration, honoured by the transpiler, the CSS pipeline and the polyfill injector. Worth setting deliberately rather than inheriting.
npx browserslist # what does this resolve to?Run that. The resolved list is frequently longer and older than anyone expects, and it silently sets the cost of every build.
Polyfills, and the differential option
// syntax — transpiled away
const x = a ?? b
// runtime API — needs a polyfill
[1,2,3].at(-1)
Object.hasOwn(obj, 'key')core-js with useBuiltIns: 'usage' injects only the polyfills your code actually needs, which is far smaller than including the whole thing.
Differential serving — modern syntax to modern browsers, transpiled to old ones — was a popular pattern and has mostly stopped being worth the complexity, because the modern baseline is now so wide that the legacy bundle serves almost nobody.
CSS has the same problem
POSTCSS + AUTOPREFIXER vendor prefixes
LIGHTNING CSS downlevel nesting,
colour functions,
cascade layers
Driven by the same Browserslist config, which is why setting it correctly pays twice.
Where it goes wrong
- Targeting far older browsers than your visitors use. The most common and most expensive mistake here
- Transpiling
node_modules. Slow, usually unnecessary, and occasionally breaks packages shipping deliberately-modern code - Assuming type stripping is type checking. It isn’t, and this is worth stating whenever a build “passes”
- Polyfilling everything unconditionally rather than by usage
- Two transpilers in one pipeline — a Babel config left behind after moving to SWC, doing the work twice