| | |

Parcel 18 ๐Ÿ“ฆ Code Splitting โ€” Dynamic imports & automatic chunking

โฑ๏ธ Estimated read: 13 min | |

Parcel’s code splitting is automatic in a way that most bundlers are not. You do not configure a splitChunks cache group or a manualChunks function. You write import('./module'), and Parcel’s default bundler decides where the boundaries should be. The decision is not “every dynamic import gets its own file.” It is a graph optimization problem: deduplicate shared modules, respect the parallel request limit, avoid splitting modules that are too small to justify the extra HTTP request, and internalize async imports that are also imported synchronously in the same bundle . The dynamic import is a hint, not a command.

The v2.8.0 release replaced the original splitting algorithm with one contributed by Atlassian. The old algorithm placed assets into bundles based on manual split points and then removed duplication afterward, which was quadratic and buggy. The new algorithm starts with a graph containing no duplicationโ€”each asset lives in exactly one bundleโ€”and then combines bundles to meet constraints like the minimum bundle size and the maximum parallel request limit . The practical consequence is that the output is not predictable from the source alone. Two dynamic imports that share a dependency may produce a shared chunk, or they may not, depending on the size of the shared dependency and the number of sibling bundles the entry point already loads.

Key point: Parcel splits code automatically based on dynamic import(), but the bundler decides whether a dynamic import becomes a separate file, a shared chunk, or an internalized Promise. The @parcel/bundler-default configuration controls the thresholds, not the split points themselves.


Why the bundler decides, not the import

The HTTP/2 assumption. Parcel’s defaults are tuned for HTTP/2, where concurrent requests are cheap but not free. The maxParallelRequests setting defaults to 25 for HTTP/2 and 6 for HTTP/1. When the number of sibling bundles at a split point would exceed that limit, Parcel duplicates modules rather than creating more requests . A dynamic import that would add a 26th request is internalized or merged, even though the source code explicitly asked for a split. The bundler is optimizing for network behavior, not for matching the source structure one-to-one.

The size threshold. The minBundleSize default is 20,000 bytes for HTTP/2 and 30,000 bytes for HTTP/1. A shared module smaller than that is duplicated between the bundles that need it rather than extracted into its own chunk. The rationale is that an extra request for a 3KB module costs more in round-trip time than the bytes saved by caching it separately . A small utility library imported by two dynamic imports will be duplicated, not shared, unless the threshold is lowered.

The internalization rule. If a module is imported both synchronously and asynchronously from within the same bundle, Parcel keeps it in the synchronous bundle and wraps the async import in a Promise.resolve(). The module is not split out, because it is already loaded by the time the dynamic import executes. The documentation states this explicitly: “dynamic import is merely a hint that a dependency is not needed synchronously, not a guarantee that a new bundle will be created” . A developer who expects every import() to produce a network request will be surprised by the internalization, but the behavior is correct: the module is already in memory.

The trade-off. The automatic behavior produces good results for most applications without configuration, but it makes the bundle graph less predictable. A developer who needs a specific module to be in a separate fileโ€”for a plugin system, or for a route-level split that must be cacheable independentlyโ€”cannot force it with a dynamic import alone. The unstable_manualSharedBundles option exists for this case, but it is explicitly experimental . The default bundler is optimized for the common case, not for every case.


a. The dynamic import syntax and tree shaking

The source-level mechanism is the import() expression. It returns a Promise, works with await or .then(), and can be tree-shaken when Parcel can statically determine which exports are used.

// src/app.js โ€” dynamic import with .then()
document.querySelector('#settings').addEventListener('click', () => {
  // The module is loaded only when the button is clicked.
  // Parcel analyzes the exports used in the .then callback
  // and tree-shakes the rest.
  import('./settings.js').then(({ renderSettings }) => {
    renderSettings();
  });
});
// src/app.js โ€” dynamic import with await
async function openSettings() {
  // Destructuring allows Parcel to see which exports are used.
  // Unused exports are removed from the settings chunk.
  const { renderSettings } = await import('./settings.js');
  renderSettings();
}

The tree shaking note in the documentation is important for older browser targets: when await is transpiled away by the build, the unused exports cannot be removed. The tree shaking works because the bundler can see the destructuring pattern in the original syntax; the transpiled output obscures it . A modern browserslist configuration that preserves async/await keeps the tree shaking effective.

// โœ… Static access is tree-shakeable
const { Chart } = await import('./chart.js');

// โŒ Dynamic access disables tree shaking for the module
const name = 'Chart';
const mod = await import('./chart.js');
mod[name];

The @parcel/transformer-js diagnostic confirms this: non-static access of an import causes tree shaking to be disabled for the resolved module .


b. Shared bundles and the configuration thresholds

The shared bundle extraction is where the automatic behavior is most visible. When two entry points or two dynamic imports share a dependency, Parcel extracts that dependency into a shared chunk if it meets the size and count thresholds.

// package.json โ€” adjusting the default bundler thresholds
{
  "@parcel/bundler-default": {
    "minBundles": 1,
    "minBundleSize": 10000,
    "maxParallelRequests": 15
  }
}

The minBundles value is the number of bundles that must use an asset before it is considered for extraction. The default is 1, which means an asset used by more than one bundle is a candidate . The minBundleSize lowers the threshold at which the candidate is actually extracted. Setting it to 10,000 bytes makes Parcel extract smaller shared modules than the default 20,000. The maxParallelRequests limits the number of sibling bundles at a split point; lowering it to 15 forces more duplication when the request count would exceed that limit.

// src/home.js
import { render } from './render.js'; // shared with profile.js
render();

// src/profile.js
import { render } from './render.js'; // shared with home.js
render();

If render.js is 25,000 bytes, the default thresholds extract it into a shared chunk. Both home.js and profile.js load it via a separate <script> tag. If render.js is 8,000 bytes, it is duplicated into both bundles, because the extra request for 8KB costs more than the duplication. The decision is the bundler’s, and the configuration is the only way to change it.


Complete Example Session

// ============================================
// PART 1: THE DYNAMIC IMPORT
// ============================================

// src/app.js
const button = document.querySelector('#load-dashboard');

button.addEventListener('click', async () => {
  // The dynamic import creates a split point.
  // Parcel decides how to bundle the dashboard module.
  const { renderDashboard } = await import('./dashboard.js');
  renderDashboard();
});

// ============================================
// PART 2: THE DASHBOARD MODULE
// ============================================

// src/dashboard.js
// This module imports a heavy charting library.
// The charting library is the candidate for a shared chunk
// if it is also used by other dynamic imports.
import { Chart } from './chart.js';

export function renderDashboard() {
  const chart = new Chart(document.querySelector('#chart'));
  chart.render();
}

// ============================================
// PART 3: THE SHARED DEPENDENCY
// ============================================

// src/reports.js
// Another dynamic import that uses the same charting library.
import { Chart } from './chart.js';

export function renderReports() {
  const chart = new Chart(document.querySelector('#reports-chart'));
  chart.render();
}

// When both dashboard.js and reports.js are dynamically imported,
// chart.js is shared between two async bundles.
// Parcel extracts it into a shared chunk if it meets the thresholds.

// ============================================
// PART 4: THE OUTPUT (DEPENDS ON THRESHOLDS)
// ============================================

// If chart.js is > 20KB (default minBundleSize):
// dist/app.js
// dist/dashboard.[hash].js  โ€” imports chart.[hash].js
// dist/reports.[hash].js    โ€” imports chart.[hash].js
// dist/chart.[hash].js      โ€” shared chunk
//
// If chart.js is < 20KB:
// dist/app.js
// dist/dashboard.[hash].js  โ€” contains chart.js inline
// dist/reports.[hash].js    โ€” contains chart.js inline
// (duplication, no shared chunk)

// ============================================
// PART 5: THE INTERNALIZATION CASE
// ============================================

// src/app.js
import { formatDate } from './date.js'; // synchronous import

async function showDate() {
  // The dynamic import is a hint, but date.js is already
  // in the synchronous bundle.
  const { formatDate } = await import('./date.js');
  console.log(formatDate(new Date()));
}

// Parcel internalizes the async import.
// date.js is not split into a separate chunk.
// The dynamic import becomes Promise.resolve().
// No new network request is made.

// ============================================
// PART 6: THE THRESHOLD CONFIGURATION
// ============================================

// package.json โ€” lowering the size threshold
{
  "@parcel/bundler-default": {
    "minBundleSize": 5000
  }
}

// With this, a 6KB shared module is extracted into a chunk
// instead of being duplicated. The trade-off is more requests.

// ============================================
// PART 7: THE HTTP/1 SHORTHAND
// ============================================

// package.json โ€” HTTP/1 defaults
{
  "@parcel/bundler-default": {
    "http": 1
  }
}

// This sets:
// minBundles: 1
// minBundleSize: 30000
// maxParallelRequests: 6
//
// The HTTP/1 defaults are more conservative because
// browsers limit concurrent connections per host.

// ============================================
// PART 8: THE TREE SHAKING WITH DYNAMIC IMPORTS
// ============================================

// src/utils.js
export function used() { return 'used'; }
export function unused() { return 'unused'; }

// src/app.js
async function load() {
  // Destructuring allows Parcel to tree-shake `unused`.
  const { used } = await import('./utils.js');
  used();
}

// Output: the utils chunk contains `used` but not `unused`,
// provided `await` is not transpiled away.

// ============================================
// PART 9: THE MANUAL SHARED BUNDLE (EXPERIMENTAL)
// ============================================

// package.json โ€” experimental manual bundling
{
  "@parcel/bundler-default": {
    "unstable_manualSharedBundles": [
      {
        "name": "vendor",
        "root": "src/app.js",
        "assets": ["node_modules/react/**"]
      }
    ]
  }
}

// This forces React into a manual shared bundle.
// The feature is experimental and may behave unexpectedly.

// ============================================
// PART 10: VERIFICATION
// ============================================

// 1. Build: parcel build src/index.html
// 2. List the output directory: ls dist/
// 3. Count the JS files.
// 4. If there are more files than entry points, splitting occurred.
// 5. Check the HTML: verify the <script> tags for shared chunks.
// 6. Adjust minBundleSize and rebuild to see the difference.

The ten parts covered the dynamic import syntax, the tree shaking behavior, the shared chunk extraction, the internalization rule, the threshold configuration, the HTTP/1 shorthand, and the experimental manual bundles. The through-line is that the bundler’s decisions are constrained by network behavior, not by the source’s split points.


Quick Reference

Bundler defaults

OptionHTTP/1HTTP/2 (default)
minBundles11
minBundleSize30,00020,000
maxParallelRequests625

Dynamic import behavior

ScenarioBehavior
Async-only importSplit point; may become a chunk
Async + sync in same bundleInternalized; no new chunk
Shared by multiple async bundlesShared chunk if above threshold
Static destructuringTree-shakeable
Dynamic property accessTree shaking disabled

Configuration key

KeyLocation
@parcel/bundler-defaultpackage.json

Best Practices

โœ… Do This:

// Use static destructuring for tree shaking
const { Chart } = await import('./chart.js');                    // โœ…

// Use dynamic imports for route-level or feature-level splits
button.addEventListener('click', () => import('./feature.js'));  // โœ…

// Adjust minBundleSize for small shared modules
"minBundleSize": 10000                                            // โœ…

โŒ Don’t Do This:

// Use dynamic property access on the imported module
const name = 'Chart'; mod[name];                                  // โŒ

// Expect every import() to create a network request
// Internalization may keep the module in the parent.             // โŒ

// Use dynamic import in service workers or worklets
import('./feature.js'); // Not allowed in those contexts           // โŒ

Common Pitfalls

PitfallWhy It HappensFix
Shared module is duplicatedBelow minBundleSize thresholdLower minBundleSize
Dynamic import makes no requestModule also imported synchronouslyAccept the internalization
Tree shaking does not remove exportsawait transpiled awayUse a modern browserslist target
Too many parallel requestsDefaults tuned for HTTP/2Set http: 1 or lower maxParallelRequests
Dynamic import fails in workerNot supported in worklets/service workersUse static imports

Real-World Examples

1. Basic dynamic import

import('./settings.js').then(m => m.render());

2. Async/await with destructuring

const { Chart } = await import('./chart.js');

3. Shared chunk extraction

// chart.js is shared between dashboard.js and reports.js
// Extracted if > 20KB

4. Threshold configuration

{ "@parcel/bundler-default": { "minBundleSize": 10000 } }

5. HTTP/1 defaults

{ "@parcel/bundler-default": { "http": 1 } }

6. Internalization case

import { format } from './date.js'; // sync
await import('./date.js'); // internalized

7. Tree shaking with dynamic import

const { used } = await import('./utils.js'); // unused removed

8. Dynamic access disables tree shaking

mod[name]; // tree shaking disabled

9. Service worker limitation

// import() not allowed in service workers

10. Verification

ls dist/ # count the JS files

Visual

The Split Point Decision

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  WHAT HAPPENS TO import('./module.js')                        โ”‚
โ”‚                                                              โ”‚
โ”‚  Is the module imported synchronously in the same bundle?    โ”‚
โ”‚         โ”‚                                                    โ”‚
โ”‚         โ”œโ”€โ”€ Yes โ”€โ”€โ–ถ INTERNALIZED                             โ”‚
โ”‚         โ”‚           Kept in parent bundle                    โ”‚
โ”‚         โ”‚           No new network request                   โ”‚
โ”‚         โ”‚                                                    โ”‚
โ”‚         โ””โ”€โ”€ No โ”€โ”€โ–ถ Is the module shared with another         โ”‚
โ”‚                     async bundle?                            โ”‚
โ”‚                            โ”‚                                 โ”‚
โ”‚                            โ”œโ”€โ”€ Yes โ”€โ”€โ–ถ Is it above           โ”‚
โ”‚                            โ”‚           minBundleSize?        โ”‚
โ”‚                            โ”‚              โ”‚                  โ”‚
โ”‚                            โ”‚              โ”œโ”€โ”€ Yes โ”€โ”€โ–ถ SHARED โ”‚
โ”‚                            โ”‚              โ”‚           CHUNK  โ”‚
โ”‚                            โ”‚              โ””โ”€โ”€ No โ”€โ”€โ–ถ DUPLICATEDโ”‚
โ”‚                            โ”‚                                 โ”‚
โ”‚                            โ””โ”€โ”€ No โ”€โ”€โ–ถ SEPARATE CHUNK         โ”‚
โ”‚                                       (if above threshold)   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Shared Bundle Threshold

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  minBundleSize DECIDES                                         โ”‚
โ”‚                                                              โ”‚
โ”‚  Shared module: 8KB              Shared module: 25KB         โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”        โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     โ”‚
โ”‚  โ”‚ Below 20KB default  โ”‚        โ”‚ Above 20KB default  โ”‚     โ”‚
โ”‚  โ”‚ โ†’ Duplicated        โ”‚        โ”‚ โ†’ Extracted         โ”‚     โ”‚
โ”‚  โ”‚                     โ”‚        โ”‚                     โ”‚     โ”‚
โ”‚  โ”‚ app.js: 8KB         โ”‚        โ”‚ app.js              โ”‚     โ”‚
โ”‚  โ”‚ page.js: 8KB        โ”‚        โ”‚ page.js             โ”‚     โ”‚
โ”‚  โ”‚ (16KB total)        โ”‚        โ”‚ shared.[hash].js    โ”‚     โ”‚
โ”‚  โ”‚                     โ”‚        โ”‚ (25KB, cached)      โ”‚     โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜        โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜     โ”‚
โ”‚                                                              โ”‚
โ”‚  The extra request for 8KB costs more than the duplication. โ”‚
โ”‚  The 25KB module is worth caching separately.               โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The Parallel Request Limit

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  maxParallelRequests CAPS THE SIBLINGS                        โ”‚
โ”‚                                                              โ”‚
โ”‚  Entry point loads 25 sibling bundles (HTTP/2 default)       โ”‚
โ”‚                                                              โ”‚
โ”‚  A 26th dynamic import would exceed the limit.              โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”                                    โ”‚
โ”‚  โ”‚ Parcel duplicates   โ”‚  The module is kept in the parent  โ”‚
โ”‚  โ”‚ the module instead  โ”‚  rather than adding a 26th request โ”‚
โ”‚  โ”‚ of splitting it     โ”‚                                    โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜                                    โ”‚
โ”‚                                                              โ”‚
โ”‚  HTTP/1 default is 6. Lowering the limit forces more        โ”‚
โ”‚  duplication but fewer concurrent requests.                 โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Summary

ItemValue
Split triggerDynamic import()
Default bundler@parcel/bundler-default
minBundleSize (HTTP/2)20,000 bytes
minBundleSize (HTTP/1)30,000 bytes
maxParallelRequests (HTTP/2)25
maxParallelRequests (HTTP/1)6
InternalizationAsync import also imported synchronously
Tree shakingStatic access only

Key takeaways:

  • Dynamic import is a hint, not a command. Parcel decides whether the import becomes a separate chunk, a shared bundle, or an internalized Promise based on size thresholds and the parallel request limit. The source code does not control the output structure directly .
  • Shared modules are extracted only when they exceed the size threshold. A module smaller than minBundleSize is duplicated between the bundles that need it rather than extracted. The default is 20KB for HTTP/2 and 30KB for HTTP/1 .
  • The parallel request limit causes duplication. When the number of sibling bundles would exceed maxParallelRequests, Parcel duplicates modules rather than adding more requests. The default is 25 for HTTP/2 and 6 for HTTP/1 .
  • Internalization occurs when a module is both synchronously and asynchronously imported. The module stays in the parent bundle and the dynamic import is wrapped in a Promise. No new network request is made .
  • Tree shaking with dynamic imports requires static access. Destructuring or static property access on the imported module allows unused exports to be removed. Dynamic property access disables tree shaking for that module .

Remember: Parcel’s code splitting is a network optimization, not a source-code transformation. The bundler is trying to produce the smallest total transfer and the fastest load time given the HTTP version and the size of the modules. The dynamic import tells it where a potential boundary is; the bundler decides whether drawing that boundary is worth it. The configuration exists to adjust the thresholds when the defaults do not match the application’s network profile. The source code expresses intent; the bundler expresses the constraints.

Was this helpful?


Stop using slow, ad-bloated tool sites! ๐Ÿคฎ

๐Ÿ”Ž Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
โœ… Finance (Mortgage, Interest, Inflation)
โœ… Tech (Base64, JSON, Dev Suite, IP)
โœ… Health (BMI, BMR, TDEE)
โœ… Productivity (Timer, Workspace, QR)

โšก๏ธ Fast & Private
๐Ÿ”’ No data leaves your device
๐Ÿ’Ž 100% Free

๐Ÿ”— Use it now: https://tools.kandz.me
๐Ÿ”– Bookmark itโ€”youโ€™ll need it later!