Code Splitting for Humans
Even in 2023, the laws of physics compel us to consider bundling JavaScript modules, optimizing our source code for end users’ benefit. Consequently, we should also be cognizant of what constitutes core functionality1 and what might be deferred via lazy loading to improve responsiveness and limit resource usage.
In order to keep things simple, let’s say our core functionality is limited to logging a bit of concocted state:
We might then use esbuild, for example, to combine those source modules into a single bundle:
$ esbuild --bundle --outfile=./bundle.js ./index.core.js
Next we’ll introduce some auxiliary functionality:
While we could add this to our existing bundle, we know it’s not essential
functionality and can easily be loaded on demand. (Obviously the assumption here
is that we’re dealing with more than just a few lines of code; everything is a
trade-off.) We also don’t want to bundle this separately, as it would result in
util.js
being duplicated across two independent bundles.
So let’s try automatic code splitting:
$ esbuild --bundle --format=esm --splitting \
--outdir=./dist ./index.*.js
This generates three bundles within the dist
directory: bundle.core.js
,
bundle.aux.js
and chunk-OL7DPBDR.js
. (That’s a lie: The actual file names
within dist
are also index.core.js
and index.aux.js
– we wanna avoid
that ambiguity in this context.)
Here we have our two entry points and a module shared by both. The latter
contains util.js
‘s log
function, as it’s used by both entry points.
Consequently, both of those entry points now include this line:
import { log } from "./chunk-OL7DPBDR.js";
Other than that, bundle.core.js
looks just like its original source module.
bundle.aux.js
, on the other hand, now includes trig.js
.
So that’s a pretty decent result; we might stop here. However, while our initial
bundle.js
was entirely self-contained, bundle.index.js
now includes an
import
statement, requiring an additional network request before being
operational. In other words: We’ve sacrificed initial-load performance to
improve efficiency for the auxiliary bundle.
Let’s rectify that:
bundle.core.js
now doubles as entry point and library, exposing log
– which
index.aux.js
can now import from here instead. As such, bundle.core.js
should be fully self-contained again.
Unfortunately though, automatic code splitting doesn’t reliably understand what
we’re trying to achieve there2: log
is still relegated to a
separate chunk file. That’s one example of why I’m skeptical of yielding control
to some inscrutable algorithm: The consequences are sometimes unpredictable,
especially in more complex scenarios, checking and evaluating results requires a
lot of discipline. (Sadly, few people ever check generated bundles’ contents in
the first place.)
We can work around that though by assuming manual control:
$ esbuild --bundle --format=esm --external:./bundle.*.js \
--outdir=./dist ./index.*.js
In addition to disabling automatic code splitting, we’ve marked bundle.core.js
as external so our bundler won’t resolve that reference, leaving the respective
import
statement unchanged:
This is exactly what we’d been aiming for! But we’re not quite done yet.
So far we’ve ignored that something needs to load that auxiliary functionality:
Thanks to our bundle.*.js
exclusion pattern above, this dynamic import won’t
be resolved at build time either, relying on lazy loading at runtime instead.
So bundle.core.js
now loads bundle.aux.js
, which in turn loads
bundle.core.js
. It’s worth noting that this last step just receives a
reference to the previously loaded module (the very same instance, if you will),
so we could even use this to share state:
Finally, if we revert our renaming lie above, ensuring that entry-point modules
and corresponding bundles use identical names, static code analysis (e.g.
linters or TypeScript) should be unfazed by this setup, interpreting imports
from index.core.js
at the source-module level while at runtime the same
references work with bundles.
However, this manual approach also has its drawbacks: It requires a precise understanding of which pieces of code should be shared between bundles (though arguably we shouldn’t have many such interconnections in the first place), along with a healthy dose of discipline. Over time this, too, might result in inefficiencies.