Parcel 18 ๐ฆ Code Splitting โ Dynamic imports & automatic chunking
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
| Option | HTTP/1 | HTTP/2 (default) |
|---|---|---|
minBundles | 1 | 1 |
minBundleSize | 30,000 | 20,000 |
maxParallelRequests | 6 | 25 |
Dynamic import behavior
| Scenario | Behavior |
|---|---|
| Async-only import | Split point; may become a chunk |
| Async + sync in same bundle | Internalized; no new chunk |
| Shared by multiple async bundles | Shared chunk if above threshold |
| Static destructuring | Tree-shakeable |
| Dynamic property access | Tree shaking disabled |
Configuration key
| Key | Location |
|---|---|
@parcel/bundler-default | package.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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Shared module is duplicated | Below minBundleSize threshold | Lower minBundleSize |
| Dynamic import makes no request | Module also imported synchronously | Accept the internalization |
| Tree shaking does not remove exports | await transpiled away | Use a modern browserslist target |
| Too many parallel requests | Defaults tuned for HTTP/2 | Set http: 1 or lower maxParallelRequests |
| Dynamic import fails in worker | Not supported in worklets/service workers | Use 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
| Item | Value |
|---|---|
| Split trigger | Dynamic 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 |
| Internalization | Async import also imported synchronously |
| Tree shaking | Static 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
minBundleSizeis 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.
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!