Skip to content

turbopack: experimental minify-before-chunking feature - #98760

Open
andrewimm wants to merge 2 commits into
canaryfrom
andrewimm/minify-before-chunk
Open

andrewimm wants to merge 2 commits into
canaryfrom
andrewimm/minify-before-chunk

Conversation

@andrewimm

@andrewimm andrewimm commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Minification happens on a per-chunk basis. With enough chunks, we get good parallelization, but a large chunk can create a single large span that wastes unused cores.
This flag makes minification more granular, applying it to each module and caching the result. The goal is to make each minification step smaller, so that they can be evenly distributed across parallel workers. Theoretically this may also reduce the work done on a re-build, since the minification transform will only be applied to changed modules.

The flag is configured behind an experimental config flag, and only applies to builds (because we don't minify in dev!).

An ideal outcome is equal-or-faster builds, with almost no change to byte size. I don't expect much change to byte size, becuase the shape of our chunks doesn't lend itself to cross-module optimizations, but it's entirely possible we regress this a bit. That hypothesis will need to be tested against our benchmark suite of real Next apps.


Update: size measurements and follow-up changes

Changes in the second commit

  • Bug fix: per-item minification dropped the additional module ids of merged (scope hoisted) factories. The factory code is id2, id3, (ctx) => {…}; it was parsed as a sequence expression and SWC compressed the ids away. next build of bench/heavy-npm-deps failed with module factory is not available. The ids are now split off before minifying and re-emitted unchanged.
  • minify and minify_chunk_item share a single parse/optimize/print pipeline (review feedback).
  • The flag is now wired into NodeJsChunkingContext (the snapshot tests weren't exercising the per-item path before) and into the Next.js server and edge chunking contexts.
  • Chunk scaffolding is written already minified in all modes. All-strict chunks get a file-level "use strict" instead of a strict IIFE, and strict chunk items sort first.
  • In per-item mode, strict factories drop their own "use strict" and the chunk always places them in a strict context. Per-item mangling uses Deterministic (no char-frequency ordering), so names are consistent across factories and compress better.

Output size: per-item vs whole-chunk minification

Current (rebased onto canary 2420b9f578, with swc-project/swc#12580)

Canary now has swc_ecma_minifier 63.0.4, which keeps the "use strict" directive of a function body (swc-project/swc#12580). The whole-chunk baseline is therefore no longer artificially small. Production next build of each app, flag on (per-item) vs flag off (whole-chunk) on this branch. Sum of every .js under .next/static + .next/server, each file compressed separately (gzip -9, brotli -11). One run per variant. Re-measured after canary's chunk-group changes (#98734, #99750, #98685, #99050): every emitted chunk file is byte-for-byte identical to the previous run on 2a697ac4a7, for all three variants, so only manifest metadata moved.

per-item vs whole-chunk raw gzip brotli
bench/nested-deps-app-router-many-pages (100 pages, 5.26 MB) +4,157 B (+0.08%) +4,684 B (+0.29%) +7,856 B (+0.57%)
↳ client chunks +0 B +625 B (+0.26%) +2,245 B (+1.14%)
bench/heavy-npm-deps (1.40 MB) +46 B (+0.003%) +869 B (+0.20%) +53 B (+0.01%)
  • Raw: all of the remaining raw overhead is the strict IIFE wrapper on small mixed chunks: 306 SSR chunks in many-pages, 4 in heavy-npm-deps, +5–18 B each. Per-item factories drop their own directive, so the chunk wraps them even when there are too few to be worth it in whole-chunk mode.
  • Compressed: the rest comes from placement and mangling. Deterministic per-factory names compress slightly worse than whole-chunk frequency-ordered names, even where raw size is identical.
  • Flag off vs canary: the minified scaffolding is −40 B raw per app and within ±0.05% compressed.
  • Caveat: changed chunk hashes and build IDs in manifests account for part of the small compressed deltas. This isn't an SWC-only ablation, since canary moved too.

Previous (before the SWC fix, base d8bfc3c994)

Flag on vs off, minification enabled in both. .js under .next/static + .next/server, gzip -9. next-swc was built from this branch in the debug profile (output bytes don't depend on the profile).

before (first commit) raw / gzip after raw / gzip
bench/heavy-npm-deps, all JS +0.41% / +0.89% +0.03% / +0.21%
bench/nested-deps-app-router-many-pages (100 pages), all JS +0.71% / +1.11% +0.19% / +0.48%
↳ client chunks +0.96% / +2.39% +0.01% / +0.26%
turbopack snapshot inputs (minify forced on) +1.19% / +1.27% +0.16% / +0.38%

Where the original overhead came from (e.g. a 183 KB client chunk with 106 factories, +1,612 B):

  • ~1,378 B: redundant "use strict" in every strict factory, since the chunk-level minifier normally strips these
  • ~250 B: unminified chunk scaffolding (newlines, ", ", globalThis["TURBOPACK"] || …)
  • the gzip-only part: each factory picking its own mangled-name order

At the time, part of the gap was a whole-chunk bug: SWC dropped the directive of the mixed-mode strict IIFE ([(()=>{"use strict";return[…]})(), …]), so strict factories ran sloppy and the baseline was smaller than correct output. swc-project/swc#12580 fixed this, and the numbers above are measured against the fixed baseline.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: e0b7568

@@ -0,0 +1,9 @@
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The minifyBeforeChunking snapshot tests run on the NodeJs runtime, but NodeJsChunkingContext never plumbs the flag, so it always returns the trait default false and the new per-item minify path is never exercised.

Fix on Vercel

Comment on lines +235 to +239
pub fn minify_chunk_item(
code: &Code,
source_maps: bool,
mangle: Option<MangleType>,
) -> Result<Code> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We already have a

pub fn minify(code: Code, source_maps: bool, mangle: Option<MangleType>) -> Result<Code> {

function in the same file. How is this one different?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The original minify was written to handle chunks with side-effectful code, so it avoids eliminating any expressions.

Since a module is just an arrow function, it has no side effect and the minifier tries to delete the statement as a no-op. To get around this, I modify the AST to assign the arrow function to a variable called __TURBOPACK_CHUNK_ITEM__, minify it, and then delete the assignment. This leaves the minified arrow function.

But you're right that I'm probably duplicating code to achieve this. PRobably the right approach is:

  1. move most of minify to a core that handles a pre-parsed AST
  2. minify becomes a wrapper that parses the raw code and passes the AST to the implementation
  3. minify_chunk_item becomes a wrapper that generates the artificial AST, then passes it to the inner implementation method

That, or I add an optional pre- and post-processing step to the AST in minify

@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from fe1ba07 to a1ac34b Compare October 10, 2026 17:23
@github-actions github-actions Bot added created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. type: next labels Oct 10, 2026
@lukesandberg
lukesandberg added this pull request to stack #99984 October 10, 2026 18:05
@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from a1ac34b to 060bad6 Compare October 24, 2026 17:41
vercel-fleet-prod Bot and others added 2 commits October 24, 2026 20:59
Minification happens on a per-chunk basis. With enough chunks, we get good
parallelization, but a large chunk can create a single large span that
wastes unused cores.
This flag makes minification more granular, applying it to each module and
caching the result. The goal is to make each minification step smaller, so
that they can be evenly distributed across parallel workers.
Theoretically this may also reduce the work done on a re-build, since the
minification transform will only be applied to changed modules.

The flag is configured behind an `experimental` config flag, and only
applies to builds (because we don't minify in dev!).

An ideal outcome is equal-or-faster builds, with almost no change to
byte size. I don't expect much change to byte size, becuase the shape
of our chunks doesn't lend itself to cross-module optimizations, but
it's entirely possible we regress this a bit. That hypothesis will
need to be tested against our benchmark suite of real Next apps.

Co-authored-by: Andrew Imm <andrew.imm@vercel.com>
Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
- Share one parse/optimize/print pipeline between `minify` and `minify_chunk_item`.
- Wire `minify_before_chunking` into the Node.js chunking context and the Next.js
  server and edge chunking contexts (previously only client chunks used it).
- Fix per-item minification dropping the additional module ids of merged
  (scope hoisted) factories (`id2, id3, <factory>` was parsed as a sequence and
  the ids compressed away).
- Move the per-item vs whole-chunk decision into `ChunkMinification`.
- Write chunk scaffolding in minified form in all modes, use a file-level
  `"use strict"` for all-strict chunks, and sort strict chunk items first.
- In per-item mode, strip each strict factory's own directive and always group
  strict factories under a strict wrapper, and use deterministic mangling so
  names are consistent across factories.

Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>
@lukesandberg
lukesandberg force-pushed the andrewimm/minify-before-chunk branch from 060bad6 to e0b7568 Compare October 24, 2026 20:59
}

#[test]
fn use_strict_directive_is_kept() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

some of these tests are just testing the swc behavior, we should drop them

}

#[test]
fn use_strict_directive_can_be_stripped() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

so this is testing an SWC behavior where directives can be stripped if they don't change semantics?

@@ -1,12 +1,10 @@
(()=>{"use strict";(globalThis["TURBOPACK"] || (globalThis["TURBOPACK"] = [])).push(["output/1jsg_tests_snapshot_basic-tree-shake_import-side-effect_input_1_hdh9its1vdz._.js",
"[project]/turbopack/crates/turbopack-tests/tests/snapshot/basic-tree-shake/import-side-effect/input/index.js [test] (ecmascript)", ((__turbopack_context__) => {
"use strict";(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["output/1jsg_tests_snapshot_basic-tree-shake_import-side-effect_input_1_hdh9its1vdz._.js","[project]/turbopack/crates/turbopack-tests/tests/snapshot/basic-tree-shake/import-side-effect/input/index.js [test] (ecmascript)",((__turbopack_context__) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

im confused how use strict wound up here? does this need to be regenerated?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

created-by: Turbopack team PRs by the Turbopack team. Turbopack Related to Turbopack with Next.js. type: next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants