Skip to content

feat(babel): add parallel processing via worker threads#1956

Open
davidtaylorhq wants to merge 1 commit intorollup:masterfrom
davidtaylorhq:babel-parallel
Open

feat(babel): add parallel processing via worker threads#1956
davidtaylorhq wants to merge 1 commit intorollup:masterfrom
davidtaylorhq:babel-parallel

Conversation

@davidtaylorhq
Copy link
Contributor

@davidtaylorhq davidtaylorhq commented Jan 12, 2026

Rollup Plugin Name: @rollup/plugin-babel

This PR contains:

  • bugfix
  • feature
  • refactor
  • documentation
  • other

Are tests included?

  • yes (bugfixes and features will not be merged without tests)
  • no

Breaking Changes?

  • yes (breaking changes will not be merged unless absolutely necessary)
  • no

If yes, then include "BREAKING CHANGES:" in the first commit message body, followed by a description of what is breaking.

List any relevant issue numbers: n/a

Description

feat(babel): add parallel processing via worker threads

Add a parallel option that processes files concurrently using Node.js worker threads. This reduces build times for large projects where Babel transformation is a bottleneck. This is similar to the existing parallel behavior of @rollup/plugin-terser.

This required some fairly significant refactoring, because we can only pass serializable objects between the main thread and the worker threads. It also required changes to the plugin's own build config, so that we can generate a dedicated worker entrypoint.

Validations are added to ensure that unserializable config (e.g. inline babel plugins) cannot be used alongside the new parallel mode. For people using dedicated babel config files, this isn't a problem, because they are loaded directly by babel in the worker thread itself.

The worker threads do have a setup cost, so this only makes sense for large projects. In Discourse, enabling this parallel mode cuts our overall vite (rolldown) build time by about 45% (from ~11s to ~6s) on my machine.


This PR is based on #1954, which should ideally be merged first. Happy to rebase this one on main if the other PR isn't mergable.

For testing, I've pushed a built version of this branch to https://github.com/davidtaylorhq/plugins/tree/babel-parallel-built. In pnpm, it can be installed like:

"@rollup/plugin-babel": "davidtaylorhq/rollup-plugins#babel-parallel-built&path:/packages/babel",

@davidtaylorhq
Copy link
Contributor Author

davidtaylorhq commented Feb 5, 2026

@Andarist @shellscape sorry for the ping, but just wanted to check if this is something you'd consider looking at? The performance benefits are pretty massive for large projects using Babel.

@NullVoxPopuli
Copy link

I'm really looking forward to this landing! I have a local tarball that I've been copying into my projects to have the benefits of this PR - and it's really good. I can no longer imagine using babel in rollup/vite/rolldown without the parallel mode <3

(usage without parallel mode is painfully slow)


const parallelWorkerCount = typeof parallel === 'number' ? parallel : 4;
workerPool = new WorkerPool(
new URL('./worker.js', import.meta.url).pathname,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this compatible with repo-wide supported node.js version?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would ci tell us?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worker Threads and import.meta.url are both supported in Node 14 👍

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have you considered using some existing worker pool libraries?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I aimed to match the implementation of the existing Terser plugin, since I wasn't sure if adding new dependencies would be welcome. I'm certainly happy to explore it though! Did you have any particular libraries in mind?

workerpool looks like it could be a good bet 👀

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it's fine to keep it as-is if it matches the one in the terser plugin. It would be nice to extract this to a shared package but I don't know what's the proper drill for this. It probably could/should be extracted once this PR lands.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I investigated this some more, and I do think that switching to the workerpool package is a good idea. It handles a lot more edge cases than the custom implementation, and is much less code.

I've pushed a refactor to use this. But if there are any concerns about introducing a new dependency, let me know and I can revert back to the custom implementation.

@shellscape
Copy link
Collaborator

@CharlieHelps please do a thorough review of this PR to determine if it's ready to merge. Look at the diff, reviews, comments, and referenced issues/docs/links to build a complete understanding. Then respond with a comment that includes a list of MUST fix items that must be fixed before the the PR is merged, and a list of IMPROVEMENTS that shouldn't block the merge but may be quick improvements or things that could be addressed in a follow up PR. The MUST fix items should explain exactly what's wrong and what needs to change to resolve the problem. Both should be numbered lists and the IMPROVEMENTS list should continue counting up from the MUST list. It's OK if there are no IMPROVEMENTS or MUST fix items, you can just omit the lists.

@shellscape
Copy link
Collaborator

@davidtaylorhq looks like Github's buggy UX bit us on this and I can't see/approve the CI runs. please push a new commit to the branch to trigger that UX again.

@NullVoxPopuli
Copy link

@shellscape you can close and re-open a PR to trigger that, usually -- no reason to make a contributor do a silly because GH is buggy <3

@charliecreates
Copy link
Contributor

I pulled the branch and reviewed the diff + existing review comments. Local verification for @rollup/plugin-babel (build, test, test:ts) passed; only a no-console eslint warning is present in workerPool.terminate().

MUST fix

  1. Windows/URL-safe worker entry resolution: In packages/babel/src/index.js the worker path is built via new URL('./worker.js', import.meta.url).pathname. .pathname can be Windows-unsafe (e.g. leading /C:/...) and can be URL-encoded. Please switch to either passing the URL directly (new URL('./worker.js', import.meta.url)) or fileURLToPath(new URL('./worker.js', import.meta.url)), and keep WorkerPool compatible.
  2. Fix filter typing: packages/babel/types/index.d.ts currently declares filter?: (id: string, code: string) => Promise<boolean>; but the implementation supports sync filters too. Change this to boolean | Promise<boolean> so TS users aren’t forced async.
  3. Validate numeric parallel values: const parallelWorkerCount = typeof parallel === 'number' ? parallel : 4; accepts 0, negatives, NaN, and floats. Some of these can yield a pool that never creates workers and transforms that never resolve. Please guard with something like Number.isInteger(parallel) && parallel > 0 and throw a clear error otherwise.
  4. Confirm the Rollup support change is intentional: This PR’s commits drop Rollup ^1.20.0 from peerDependencies (now ^2 || ^3 || ^4). If that’s intentional, it should be explicitly called out (and semver/release notes aligned). If it’s not intended as part of this PR, please revert that portion (or split it out).
  5. CI signal before merge: gh pr checks 1956 currently shows “no checks reported”. Please rebase/push or have a maintainer trigger workflows so the PR has a visible CI pass before merging.

IMPROVEMENTS

  1. Remove/avoid console.error in library code: packages/babel/src/workerPool.js logs on terminate failure (console.error('Error terminating worker:', err)) and triggers no-console. Consider swallowing termination errors, or surfacing them via rejected promises instead of logging.
  2. Defensive postMessage error handling: worker.postMessage(task.opts) can throw synchronously (e.g. DataCloneError) if something slips past the serializability guard. Wrapping this in try/catch and rejecting with a targeted “non-serializable babel options in parallel mode” error would make failures much clearer.
  3. Default worker count rationale: Not blocking, but it’d be good to either document why the default is 4 or consider a CPU-based default (with a cap).
  4. Resolve/close review threads: There are still unresolved threads (Node compat / worker-pool library discussion). Not strictly code-blocking, but cleaning those up will make merge readiness clearer.

Comment on lines +36 to +39
transformOptions =
babelHelpers === BUNDLED
? addBabelPlugin(transformOptions, bundledHelpersPlugin)
: transformOptions;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, so this now relies on babelHelpers input to skip the addBabelPlugin call in the renderChunk's case, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's right - the output plugin doesn't pass babelHelpers 👍

@shellscape shellscape changed the title feat(babel): add parallel processing via worker threads feat(babel)!: add parallel processing via worker threads Feb 16, 2026
Comment on lines +203 to +208
return workerPool.runTask({
inputCode: code,
babelOptions: { ...babelOptions, filename },
runPreflightCheck: !skipPreflightCheck,
babelHelpers
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: I'm not sure what we actually get from this.error but maybe it's worth rechecking if we shouldn't "convert" the errors from this to this.error calls?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that makes sense - that'll make things consistent in parallel/non-parallel mode. Done 👍

Comment on lines +152 to +154
if (parallel) {
const parallelAllowed =
isSerializable(babelOptions) && !overrides?.config && !overrides?.result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps it would be nice to move this validation to unpackInputPluginOptions? It would also be quite great if the thrown error message could mention the offending option.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's tricky to move it entirely to unpackInputPluginOptions, because at that point the 'overrides/customCallback' functions haven't been evaluated, and so we don't have the final babelOptions.

I have improved the error message to give a better indication of which option has failed the serializability check 👍

}

return transformCode(code, babelOptions, overrides, customOptions, this);
return transformCode({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: why we don't attempt to parallelize this one?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored so that the parallel option is available for the output plugin as well 👍

@davidtaylorhq davidtaylorhq marked this pull request as draft March 13, 2026 19:33
@davidtaylorhq davidtaylorhq changed the title feat(babel)!: add parallel processing via worker threads feat(babel): add parallel processing via worker threads Mar 16, 2026
@Andarist
Copy link
Member

Andarist commented Mar 16, 2026

@davidtaylorhq please ping me for a review explicitly when you wrap up the work here and undraft it. I'll try not to miss it

@davidtaylorhq davidtaylorhq marked this pull request as ready for review March 16, 2026 15:04
@davidtaylorhq
Copy link
Contributor Author

@Andarist I think this is in a good spot now. I've worked through all of the human & machine comments above, and I've refactored it to use the workerpool package instead of a bespoke implementation. Let me know if you have any further questions/suggestions!

I've updated the babel-parallel-built branch on my fork in case anyone wants to test the latest version against their apps (cc @NullVoxPopuli @mansona)

"@rollup/plugin-babel": "davidtaylorhq/rollup-plugins#babel-parallel-built&path:/packages/babel",

);
}

return workerPool.exec('transform', [taskOpts]).catch((err) => context.error(err.message));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: does context.error actually throws/fails the execution or does it only log the error? I hope it's the first one

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct - it fails the build. See https://rollupjs.org/plugin-development/#this-error

Structurally equivalent to this.warn, except that it will also abort the bundling process with an error.

});
}

function executeWithWorkerPool(workerPool, context, taskOpts, babelOptions) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the name of this suggests it's a generic helper for executing different tasks but actually it's only capable of calling transform. I think the name of this helper should reflect that. Please just rename this to transformWithWorkerPool and then taskOpts should become transformsOpts too

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

Comment on lines 270 to 272
if (workerPool) {
await workerPool.terminate();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this could be await workerPool?.terminate()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

Comment on lines +381 to +383
if (workerPool && !this.meta.watchMode) {
await workerPool.terminate();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: similarly here this could be simplified

Suggested change
if (workerPool && !this.meta.watchMode) {
await workerPool.terminate();
}
if (!this.meta.watchMode) {
await workerPool?.terminate();
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

Add a `parallel` option that processes files concurrently using Node.js worker threads. This reduces build times for large projects where Babel transformation is a bottleneck. This is similar to the existing parallel behavior of `@rollup/plugin-terser`, but uses the `workerpool` package instead of a custom implementation.

This required some fairly significant refactoring, because we can only pass serializable objects between the main thread and the worker threads. It also required changes to the plugin's own build config, so that we can generate a dedicated worker entrypoint.

Validations are added to ensure that unserializable config (e.g. inline babel plugins) cannot be used alongside the new parallel mode. For people using dedicated babel config files, this isn't a problem, because they are loaded directly by babel in the worker thread itself.

The worker threads do have a setup cost, so this only makes sense for large projects. In Discourse, enabling this parallel mode cuts our overall vite (rolldown) build time by about 45% (from ~11s to ~6s) on my machine.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants