Prologue: The Production Polyfill Incident at 2 AM
The monitoring alert went off at 2 AM. "Users can't log in!" I frantically opened Sentry to find Promise is not defined errors flooding in from IE11 users only. I had set up Babel, I wrote ES6 code - why was it breaking on IE11?
The answer was simple. Babel transforms syntax, but it doesn't automatically add new objects or methods (Promise, Array.includes, etc.). That's what polyfills do, and I hadn't configured core-js properly.
After this incident, I had to truly understand what transpilers do and don't do, how they differ from compilers, and how Babel's preset and plugin system actually works. This is what I learned.
The Subtle Difference Between Compilers and Transpilers
At first, they seemed similar. Both take code as input and transform it into something else. But while building frontend build pipelines, I grasped the critical difference.
Compilers change the abstraction level of the language. C code to assembly, Java to bytecode. High-level language to low-level language. Like adapting a novel into a movie script - the medium itself changes.
Transpilers transform languages at similar abstraction levels. TypeScript to JavaScript, ES2023 JavaScript to ES5 JavaScript. Changing dialects within the same medium. Like translating Korean to North Korean dialect, or British English to American English.
The official term is "Source-to-Source Compiler." It's still a form of compilation, but the purpose and output level are different - that's what clicked for me.
Why We Needed Transpilers: Browser Compatibility Hell
When ES6 (ES2015) came out in 2015, frontend developers rejoiced. const, let, arrow functions, classes, Promises, destructuring... finally, we could write code with modern syntax.
But reality was harsh. 30% of our customers still used IE11. IE11 barely supported ES6. We couldn't abandon modern syntax though. Developer experience (DX) and code quality demanded it.
The solution to this dilemma was Babel. I write code in ES6+, and Babel transforms it to ES5 at build time. Developers code in the future, users execute in browsers from the past. Time travel architecture.
How Babel Works: The World of AST Transformation
To understand exactly how Babel transforms code, I dug into its internals. The core concept is AST (Abstract Syntax Tree) transformation.
- Parse: Read source code and convert to AST
- Transform: Visit and transform AST nodes
- Generate: Convert transformed AST back to code string
For example, transforming an arrow function to a regular function:
// Input code
const add = (a, b) => a + b;
// 1. Parse - Generate AST
{
type: "ArrowFunctionExpression",
params: [{ name: "a" }, { name: "b" }],
body: {
type: "BinaryExpression",
operator: "+",
left: { name: "a" },
right: { name: "b" }
}
}
// 2. Transform - ArrowFunctionExpression -> FunctionExpression
{
type: "FunctionExpression",
params: [{ name: "a" }, { name: "b" }],
body: {
type: "BlockStatement",
body: [{
type: "ReturnStatement",
argument: {
type: "BinaryExpression",
operator: "+",
left: { name: "a" },
right: { name: "b" }
}
}]
}
}
// 3. Generate - Code generation
var add = function(a, b) {
return a + b;
};
Understanding this AST transformation process made Babel's plugin system click. Each plugin finds and transforms specific AST nodes.
Babel Presets and Plugins: Assembling LEGO Blocks
When I first configured Babel, the difference between presets and plugins was most confusing. I eventually understood it like this.
Plugin: A single syntax transformation feature. For example, @babel/plugin-transform-arrow-functions only transforms arrow functions.
Preset: A bundle of multiple plugins. @babel/preset-env includes dozens of plugins.
Adding necessary plugins one by one is inefficient. That's why we use presets in production.
// .babelrc
{
"presets": [
["@babel/preset-env", {
"targets": {
"browsers": ["> 0.5%", "last 2 versions", "not dead", "IE 11"]
},
"useBuiltIns": "usage",
"corejs": 3
}]
]
}
Let me break down what this configuration means.
targets: Defines which browsers to support. Market share above 0.5%, last 2 versions of each browser, and IE11.
useBuiltIns: "usage": This is the polyfill key. It only adds polyfills for modern features actually used in your code. Setting it to "entry" adds all polyfills needed for target browsers, bloating bundle size.
corejs: 3: Polyfill library version. Means we're using core-js@3.
Browserslist: Unified Browser Target Management
Babel isn't the only tool that needs browser targets. Autoprefixer (CSS vendor prefixes), ESLint, Webpack - many tools need this information. Configuring each separately creates inconsistencies.
That's why we use browserslist for unified configuration.
// package.json
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"IE 11"
]
}
Or create a .browserslistrc file:
# .browserslistrc
> 0.5%
last 2 versions
not dead
IE 11
Now Babel, Autoprefixer, all tools share this configuration. One setting unifies the entire build pipeline's targets.
The Polyfill Trap: Syntax vs Runtime Features
Back to the 2 AM incident. Why couldn't Babel transform Promise?
Babel only transforms syntax. Arrow functions (=>) to regular functions, const to var. But it can't transform new objects or methods (Promise, Map, Set, Array.includes, etc.). These need to exist at runtime.
That's where polyfills come in. Polyfills implement missing features in old browsers using JavaScript.
// Promise polyfill example (how core-js adds it)
if (typeof Promise === 'undefined') {
window.Promise = function(executor) {
// Promise implementation code...
};
Promise.prototype.then = function(onFulfilled, onRejected) {
// then implementation...
};
// ...
}
core-js is a library providing polyfills for all ES5+ features. Used with Babel's useBuiltIns: "usage", it automatically imports only the features you actually use.
// Code I wrote
const promise = Promise.resolve(42);
[1, 2, 3].includes(2);
// Code transformed by Babel + core-js
import "core-js/modules/es.promise.js";
import "core-js/modules/es.array.includes.js";
var promise = Promise.resolve(42);
[1, 2, 3].includes(2);
I didn't add polyfills, but Babel automatically imported what's needed. That's the magic of useBuiltIns: "usage".
TypeScript Compiler: Dual Role of Type Checking and Transformation
The TypeScript compiler (tsc) is also a transpiler. But its role differs from Babel.
tsc's two roles:
- Type Checking: Catch type errors at compile time
- Transpilation (Type Stripping + Downleveling): Remove types and transform to older JavaScript
// Input: TypeScript code (ES2020 + types)
interface User {
name: string;
age: number;
}
const getUser = async (id: number): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
return response.json();
};
const user: User = await getUser(1);
console.log(user?.name);
// Output: JavaScript code (ES5, depending on tsconfig target)
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
// async/await polyfill code...
};
var getUser = function (id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4, fetch("/api/users/" + id)];
case 1:
response = _a.sent();
return [2, response.json()];
}
});
});
};
var user = await getUser(1);
console.log(user === null || user === void 0 ? void 0 : user.name);
What tsc did:
- Removed all type information (
interface User,: number,: Promise<User>, etc.) - Transformed
async/awaitto generator-based polyfill (downleveling) - Transformed optional chaining (
?.) to ternary operators - Transformed template literals to string concatenation
This is Type Stripping (removing types) and Downleveling (transforming to lower level).
Key tsconfig.json Options
TypeScript compiler behavior is controlled by tsconfig.json.
{
"compilerOptions": {
"target": "ES5", // Output JavaScript version
"module": "commonjs", // Module system (commonjs, es6, esnext, etc.)
"lib": ["ES2020", "DOM"], // Available built-in API type definitions
"strict": true, // Strict type checking
"esModuleInterop": true, // CommonJS/ES Module compatibility
"skipLibCheck": true, // Skip type checking in node_modules
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true, // Babel/SWC compatibility (independent file transformation)
"noEmit": true // Don't generate .js files (type checking only)
}
}
target is key. Setting it to ES5 transforms arrow functions, const, classes, etc. to ES5 syntax. ES2020 leaves them as-is.
noEmit: true is interesting. Modern projects use TypeScript only for type checking, leaving actual transpilation to Babel or SWC. The reason is speed.
Babel vs TypeScript Compiler: Who Does the Transpiling?
In production, TypeScript projects often use Babel together. Why?
Using TypeScript only:
- Pros: Simple configuration. Just run
tsc - Cons: Slow. Type checking and transpiling together is slow
Using Babel + TypeScript together:
- Babel handles transpilation (using
@babel/preset-typescript) - TypeScript only handles type checking (
tsc --noEmit) - Pros: Access to Babel's rich plugin ecosystem. Babel is faster
- Cons: Complex configuration. Some TypeScript features unsupported (const enum, namespace, etc.)
I concluded:
Small projects: Just use tsc
Large projects: Babel for transpiling, TypeScript for type checking
SWC and esbuild: Ultra-Fast Transpilers in Rust/Go
The common weakness of Babel and tsc is speed. They're written in JavaScript and slow on large projects.
Enter SWC (Rust-based) and esbuild (Go-based) to solve this.
SWC (Speedy Web Compiler):
- Written in Rust
- 20x faster than Babel
- Lower Babel plugin compatibility
- Default compiler in Next.js 12+
esbuild:
- Written in Go
- Incredibly fast (100x+ faster than Babel)
- Handles bundling in one go
- Used as Vite's dev server bundler
Speed comparison on the same project:
# Same project build time
Babel: 15s
tsc: 12s
SWC: 0.8s
esbuild: 0.3s
However, SWC and esbuild don't have Babel's rich plugin ecosystem. If you need heavy custom transformations, stick with Babel. But for most common cases, SWC or esbuild is sufficient - that's what I realized.
CSS Transpilers: Sass, PostCSS
Transpilers aren't JavaScript-only. The CSS world has transpilers too.
Sass/SCSS: SCSS is a CSS superset. Provides variables, nesting, mixins, functions, etc. Browsers don't understand SCSS, so the Sass compiler transforms it to CSS.
// Input: SCSS
$primary-color: #3498db;
$padding: 16px;
.button {
background-color: $primary-color;
padding: $padding;
&:hover {
background-color: darken($primary-color, 10%);
}
&--large {
padding: $padding * 2;
}
}
/* Output: CSS */
.button {
background-color: #3498db;
padding: 16px;
}
.button:hover {
background-color: #2980b9;
}
.button--large {
padding: 32px;
}
PostCSS: PostCSS transforms CSS with JavaScript plugins. Think of it as Babel for CSS.
The most famous plugin is Autoprefixer. Write modern CSS, and it automatically adds vendor prefixes.
/* Input */
.container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* After Autoprefixer */
.container {
display: -ms-grid;
display: grid;
-ms-grid-columns: (1fr)[3];
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
PostCSS is also Tailwind CSS's core engine. It transforms Tailwind's utility classes into actual CSS.
Transpiler Integration in Build Pipelines
In real projects, transpilers aren't used standalone. They integrate with bundlers like Webpack, Vite, Rollup.
Webpack + Babel example:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx|ts|tsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-typescript',
'@babel/preset-react'
]
}
}
}
]
}
};
The flow:
- Webpack follows
importstatements to find files - When it finds
.js,.jsx,.ts,.tsxfiles, passes them tobabel-loader - Babel performs transpilation
- Webpack includes transformed code in the bundle
With Vite: Development mode uses esbuild for fast TypeScript transformation, production build uses Rollup + Babel (or SWC).
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
esbuild: {
target: 'es2015'
},
build: {
target: 'es2015'
}
});
Real Example: The Configuration That Solved the Production Issue
Here's the final configuration that solved my 2 AM incident.
// package.json
{
"browserslist": [
"> 0.5%",
"last 2 versions",
"not dead",
"IE 11"
],
"dependencies": {
"core-js": "^3.30.0"
},
"devDependencies": {
"@babel/core": "^7.21.0",
"@babel/preset-env": "^7.21.0",
"babel-loader": "^9.1.0"
}
}
// .babelrc
{
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": 3,
"modules": false,
"debug": true // Log which polyfills are added
}
]
]
}
Running the build showed this in the console:
Using polyfills with `usage` option:
[/path/to/file.js] Added following core-js polyfills:
es.promise
es.array.includes
es.object.assign
es.string.starts-with
Now I could verify that polyfills were added for every modern feature I used. Testing on IE11 again - it worked perfectly.
Transpiler Selection Criteria: What Should I Use for My Project?
I settled on this framework:
Need legacy browser support → Babel + core-js
- IE11, old mobile browsers
- Fine-grained polyfill control
- Slow but most stable
TypeScript project → tsc (type checking) + SWC/esbuild (transpiling)
- Type safety + fast builds
- Modern browsers only is fine
Development speed priority → Vite (esbuild)
- Instant dev server startup
- Very fast HMR
- Production optimized with Rollup
Large Next.js project → SWC (Next.js default)
- Built into Next.js 12+
- Rust-based speed
- Override with
.babelrcwhen needed
Closing: Transpilers Are Time Machines
Understanding transpilers revealed the essence of frontend development. We write code with future syntax, and transpilers make it work on browsers from the past.
The difference between compilers and transpilers is clear. Compilers change language levels (high-level → low-level), transpilers change dialects at the same level (ES2023 → ES5, TypeScript → JavaScript).
Babel's AST transformation, preset and plugin system, the difference between polyfills and syntax transformation, TypeScript compiler's dual role (type checking + transpiling), the speed revolution of SWC and esbuild, and even CSS transpilers.
All these tools aim for one goal: Let developers write code with the best tools, and let users execute it in any environment.
The 2 AM incident was painful, but it became an opportunity to deeply understand how transpilers work. Now I'm confident configuring polyfills, browserslist, and build pipelines.
Transpilers aren't just tools. They're the time machines of the frontend ecosystem.