TL;DR Still has caveats.
Code splitting is a feature of JavaScript bundlers that can keep huge libraries out of the main bundle.
How code splitting works
Like Webpack esbuild lets you use the await import()
function to load code on demand:
// application.js
const { fun } = await import('library.js')
fun()
However, esbuild's code splitting is disabled by default. The code above would simply
inline
Show archive.org snapshot
(copy) library.js
into application.js
:
// Compiled bundle for application.js
function require_library() {
// entire code of library.js
}
const { fun } = await Promise.resolve().then(require_library)
fun()
With splitting enabled, library.js
is extracted into a separate file ("chunk") that will be loaded over the network:
// Compiled bundle for application.js
const { fun } = await import('assets/chunk-NAXSMFJV.js')
fun()
Caveats
Currently (July 2023) code splitting in esbuild is a work in progress Show archive.org snapshot with some known caveats that will affect some projects. Below you can find examples for affected and unaffected code, so you can decide whether to care.
If you cannot live with these caveats you must leave code splitting disabled or use a more mature solution like Webpack.
Modules shared between entry points always become chunks
When two entry points import the same module, that module automatically a separate file ("chunk") that the browser must load via a separate request.
While this transformation minimizes the amount of JavaScript transmitted, it may also cause your initial bundle to be split into multiple files, which are loaded over the network. This will add at least one additional network roundtrip to your load time.
In many cases it would be preferrable to just duplicate and inline small chunks into each importing entry point. Ideally there would be a way to set a minimum file size for chunking, or to control chunking behavior per import
. However there is currently no configuration at all. Modules imported by multiple entry points always become chunks automatically.
Info
GitHub issue: Max size/request control over chunk splitting to avoid mass network requests in core scenarios Show archive.org snapshot
Example for affected code
This issue only affects builds with multiple entry points ("bundles"), for example:
require('esbuild')
.build({
entryPoints: [
'frontend.js',
'backend.js',
'specs.js',
]
// ...
})
Let's say your frontend.js
and backend.js
both do this:
import Component from 'component'
// Do something with Component
Now your component.js
lives in a separate chunk. When the browser runs either frontend.js
or backend.js
, it needs to make another network request to load the required component.js
(now named something like chunk-ASLB2CZ5.js
).
In the example above the browser would now load your frontend bundle in multiple steps:
- Load initial HTML
- Discover
<script src="frontend.js">
- Load
frontend.js
- Discover
import 'chunk-ASLB2CZ5.js'
- Load
chunk-ASLB2CZ5.js
This loading cascade may become longer if chunks depend on other chunks.
Important
When two endpoints share more than one import, all shared modules are combined into a single chunk.
E.g. when yourfrontend.js
andbackend.js
use the same 20 components, those 20 components go into a one shared chunk.
Example for unaffected code
Builds with a single entry point will not generate chunks:
require('esbuild')
.build({
entryPoints: [
'application.js',
]
// ...
})
Fixing chunks caused by JavaScript tests
Even if you only have a single user-facing endpoint, you may build an additional bundle for JavaScript tests, e.g. a specs.js
for
Jasmine
Show archive.org snapshot
specs. This is a major source for unwanted chunks, as your specs will likely import
individual components to run tests on them.
A pragmatic fix is to not build the test entry point when deploying:
const railsEnv = process.env.RAILS_ENV || 'development'
const entryPoints = [
'application.js',
]
if (railsEnv === 'development' || railsEnv === 'test') {
entry points.push('specs.js')
}
require('esbuild')
.build({
entryPoints,
// ...
})
Your load order may change
When modules are auto-extracted into their own chunks (see above), import
statements for these modules are hoisted to the top of each importing file. This may change the order of your imports.
Info
GitHub issue: Incorrect import order with code splitting and multiple entry points Show archive.org snapshot
Example for affected code
This is only an issue if all of the following conditions apply:
- module
B
is loaded after moduleA
- module
B
depends on the side effects of moduleA
, e.g.B
modifies global state defined byA
- Only module
B
becomes a chunk (whileA
is inlined)
For example, both frontend.js
and backend.js
load different jQuery variants, but
// frontend.js
import 'jquery' // defines window.jQuery
import 'select2' // plugin that hooks into window.jQuery
// backend.js
import 'jquery.slim' // defines window.jQuery
import 'select2' // plugin that hooks into window.jQuery
After chunking the load order changes so select2 is loaded before jQuery:
import 'assets/chunk_with_select2.js' // defines window.jQuery
window.jQuery = ... // inlined jQuery
Example for unaffected code
It is not an issue if either of the following conditions apply:
- the modules don't rely on each other's side effects
- modules
A
andB
are both extracted into individual chunks. - modules
A
andB
are extracted into a single chunk.
For example, when both frontend.js
and backend.js
load the same jQuery variant, both jquery and select2 are extracted into a single chunk:
// Before chunking
import 'jquery' // defines window.jQuery
import 'select2' // plugin that hooks into window.jQuery
// After chunking
import 'assets/chunk_with_both_jquery_and_select.js'
Workaround
There's a somewhat unsatisfactory workaround Show archive.org snapshot : By creating a redundant entry point you can provoke esbuild to prevent inlining and produce more chunks instead. This way you can restore the load order you require.
Setting up code spliting
Change build options
Configure the following options in your esbuild.config.js
:
require('esbuild')
.build({
// ..
chunkNames: '[dir]/[name]-[hash]',
splitting: true,
format: 'esm',
})
Load your bundle as an ESM module
esbuild only allows code splitting for the esm
format, meaning your entry point bundles are now ESM modules. This should be a compatible change, but you need to tell the browser to load your bundles as an ESM module.
To address this through all your application layouts and look for <script>
tags that load your entry point bundles:
<script src="application.js">
Now add a [type=module]
attribute:
<script src="application.js" type="module">
Lazy-loading scripts is just import()
A benefit of being an ESM module is that you can now use your browser's import()
function to lazy-load external code.
If you're using custom helpers like loadScript()
you can now replace them with just import()
.
// before
await loadScript('huge-lib.js')
// after
await import('huge-lib.js')
Run tests
Run your end-to-end tests and see if everything still works.