Code splitting in esbuild: Caveats and setup

Updated . Posted . Visible to the public.

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 your frontend.js and backend.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:

  1. module B is loaded after module A
  2. module B depends on the side effects of module A, e.g. B modifies global state defined by A
  3. Only module B becomes a chunk (while A 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 and B are both extracted into individual chunks.
  • modules A and B 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.

Henning Koch
Last edit
Dominik Schöler
License
Source code in this card is licensed under the MIT License.
Posted by Henning Koch to makandra dev (2023-07-21 13:27)