The babel plugin transform runtime (@babel/plugin-transform-runtime) does one primary job: instead of Babel pasting its helper functions into every single transpiled file, it rewrites them as imports from the @babel/runtime package, so your bundle carries each helper exactly once. Its secondary job, when configured with the corejs option, is to alias polyfills to sandboxed module imports rather than patching global built-ins. If you build a library, you almost certainly want it. If you build an application with a modern browser target, you may not need it at all. This post walks through what the transform actually changes in the output, the configuration that matters, and the dependency hygiene around @babel/runtime.
The problem: helper duplication
When Babel transpiles syntax that needs runtime support — classes, spread, async/await, destructuring — it injects small helper functions. Without the plugin, every file gets its own copy:
// output of transpiling a class, per file
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
One helper is trivial. Across five hundred modules, each carrying _classCallCheck, _createClass, _asyncToGenerator, _objectSpread, and friends, the duplication adds up to real kilobytes and — for library authors — pollutes every consumer's bundle with copies of code the consumer probably already has.
With @babel/plugin-transform-runtime enabled, the same output becomes:
import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
Now the helper is a shared module. Your bundler deduplicates it like any other import, and @babel/runtime becomes a regular production dependency in your graph.
The second job: sandboxed polyfills
Transpiling syntax is not the same as providing missing APIs. Promise, Array.from, or Object.entries on an old target need polyfills, and there are two philosophies:
- Global polyfills (
@babel/preset-envwithuseBuiltIns: 'usage'or'entry'pluscore-js): patch the global objects once. Right answer for applications — you own the page. - Sandboxed polyfills (
@babel/plugin-transform-runtimewithcorejs: 3): rewritePromiseto an import from@babel/runtime-corejs3, leaving globals untouched. Right answer for libraries — your code runs in pages you do not own, and monkey-patching your consumer's globals is rude at best and breaking at worst.
{
"plugins": [
["@babel/plugin-transform-runtime", {
"corejs": 3,
"helpers": true,
"regenerator": true
}]
]
}
With corejs: 3 you depend on @babel/runtime-corejs3 instead of plain @babel/runtime. Without the option, the plugin handles helpers only and assumes the target environment (or the consuming application) provides the APIs. That split — helpers always, polyfills only for libraries — is the configuration decision that most teams get wrong in one direction or the other.
The regenerator option covers async function support on engines without native generators by importing regenerator-runtime rather than expecting a global. On modern targets Babel compiles async/await natively and this path never activates; it exists for genuinely old targets.
Do you still need it in 2025?
Honest answer: less often than the ubiquity of the package suggests, and the dependency count tells the story — it sits in the tree of a huge share of JavaScript projects, usually via a framework preset rather than a deliberate choice.
You want it when:
- You publish a library. Helper deduplication plus non-polluting polyfills is exactly the contract a library should offer. Most component library templates enable it for this reason.
- You transpile to old targets across many modules. Big multi-page apps supporting legacy browsers see meaningful size wins from helper dedup.
You can skip it when:
- Your
browserslisttargets are modern. If@babel/preset-envemits classes, spread, andasyncnatively, there are barely any helpers to deduplicate. Check what your config actually injects before adding machinery to dedupe it —npx babel src/some-file.jsand read the output. - You use
@babel/preset-env'sbugfixesand a bundler that already scope-hoists. The residual duplication may be a rounding error against your bundle size. - You have moved to SWC or esbuild. Both have their own helper strategies (
@swc/helperswithexternalHelpers: trueis the direct analogue), and Babel-specific plugins do not carry over.
A useful check: run webpack-bundle-analyzer on a production build and search for @babel/runtime. If it is absent and your bundle has no visible helper duplication, the plugin is dead weight in your config. If helpers appear inlined dozens of times, the plugin pays for itself.
Dependency hygiene: runtime vs. plugin
A recurring source of broken builds and confused audits is which package goes where:
@babel/plugin-transform-runtime— devDependency. It runs at build time only.@babel/runtime(or@babel/runtime-corejs3) — dependency. Your shipped code imports from it at runtime.
Library authors who put @babel/runtime in devDependencies publish packages that crash on install with "Cannot find module '@babel/runtime/helpers/…'" — one of the most common packaging bugs on npm. The reverse mistake, shipping the plugin as a production dependency, quietly bloats every consumer's install with Babel internals.
Version alignment matters too: keep the plugin and the runtime package on the same Babel release line, and set the plugin's version option to the @babel/runtime version you declare so Babel can emit imports for newer helpers instead of falling back to inlining them.
From a supply chain standpoint, @babel/runtime is a production dependency like any other and shows up in your SBOM — as it should, since its code executes in your users' browsers. It is published by the Babel core team, has an enormous blast radius by download count, and is precisely the kind of infrastructure package where you want automated alerting on unusual publish activity rather than trust by habit. An SCA platform such as Safeguard treats build-time plugins and runtime packages with different exposure weights, which mirrors the devDependency/dependency split above: a compromised plugin attacks your build, a compromised runtime attacks your users.
Legacy naming: babel-runtime without the scope
Pre-2018 projects on Babel 6 used the unscoped packages: babel-plugin-transform-runtime and babel-runtime. Those are the Babel 6 generation and stopped evolving when Babel 7 moved the ecosystem to the @babel/ scope. If an audit turns up unscoped babel-runtime in a tree today, it is a marker of a very old toolchain — the fix is a Babel 7+ migration, not a version bump. The unscoped names still receive millions of downloads from legacy lockfiles, which says more about how long lockfiles live than about anyone choosing them.
FAQ
What does @babel/plugin-transform-runtime actually do?
It rewrites Babel's injected helper functions as imports from @babel/runtime so each helper exists once in your bundle instead of once per file, and — with the corejs option — aliases polyfills to sandboxed imports that do not modify global objects.
Should @babel/runtime be a dependency or devDependency?
@babel/runtime must be a production dependency because your transpiled code imports it at runtime. The plugin itself (@babel/plugin-transform-runtime) is a devDependency. Getting this backwards is a classic npm packaging bug.
Do applications need the corejs option?
Usually not. Applications typically polyfill globally via @babel/preset-env with core-js, which is simpler and deduplicates naturally. The corejs option exists mainly for libraries that must not touch their host page's globals.
Is babel-runtime the same as @babel/runtime?
No. Unscoped babel-runtime is the Babel 6-era package and is frozen; @babel/runtime is the Babel 7+ replacement. Finding the unscoped one in a modern project signals an overdue toolchain migration.