Runtime module imports in JS
The "standard" way of including js modules in your app is to bundle them together with the script that loads the app. This means that the code of the module is quite literally included in the final bundle. I was recently introduced to a client's microfrontend application at work where I learned about an alternative way to do this using SystemJS. To understand what SystemJS does though, it is necessary to first understand some different ways that we can include external modules into our frontend app.
Runtime vs build-time modules
You have probably seen this pattern of importing modules into a frontend application before:
import React from 'react'; import ReactDOM from 'react-dom'; // do stuff that uses React
with the corresponding package.json (somewhat simplified):
{ "blabla": ..., "dependencies": { "react": "^17.0.2", "react-dom": "^17.0.2", } }
Running npm install
will download the source code of those dependencies into node_modules
and running npm build
(or similar, depending on your configuration and what package manager you use) will create a single bundle. The bundle includes both your own application code and the source code of your dependencies in a single self-contained javascript file, which is optimised for sending over the internet and being interpreted by the browsers of your users. React and React DOM in the example can of course be any ES module. These modules can be referred to as build-time modules, since they are included in an application during the build step.
The other potential option that I learned about recently is to actually include those modules first at runtime, which is when the code is running inside the browser in the case of most frontend applications. That means that there is no build step necessary per se for including external modules (although you might want one for other reasons), and that these can be loaded as needed rather than all at once. These modules are sometimes referred to as runtime or browser modules, since the browser is a javascript runtime.
The arguably easiest way to import modules at runtime is to simply use the full URL as the import identifier, for common packages there are CDNs like jsDelivr that offer pre-built bundles. Our App.js file might then look something like this:
import React from 'https://cdn.jsdelivr.net/npm/react@18.2.0/+esm'; import ReactDOM from 'https://cdn.jsdelivr.net/npm/react-dom@18.2.0/+esm'; const name = 'In-Browser React!!' ReactDOM.render(React.createElement('div', null, `Hello ${name}!`), document.getElementById('root'));
We can include our app in a script tag with type="module"
. This works since most browsers have had support for ES modules for some years now.
<!DOCTYPE html> <html> <head> <title>Hello In-Browser React</title> <meta charset="utf-8"> </head> <body> <div id="root"> <!-- app renders here<!-- --> </div> <script type="module" src="./App.js"></script> </body> </html>
If you serve a directory with two files like these and watch the network tab while loading, you should see index.html
and App.js
load first, followed by one request for each of the externally loaded dependencies.
Enter the import map
This works, but one could imagine our JS files looking cluttered pretty quickly. We can simplify our imports by introducing an import-map, which is a simple JSON object that maps identifiers to URLs for the imports we would like to use. We can do this by simply adding a script tag of type=importmap
that contains our map to index.html
:
<script type="importmap"> { "imports": { "react": "https://cdn.jsdelivr.net/npm/react@18.2.0/+esm", "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18.2.0/+esm" } } </script>
Now we can go back to importing the modules by using only their identifiers, like in the first example:
import React from 'react'; import ReactDOM from 'react-dom'; const name = 'Import-map React!!' ReactDOM.render(React.createElement('div', null, `Hello ${name}!`), document.getElementById('root'));
When serving the files with these modifications you should see the same sequence of network request as before. Although the specification has been around for a while, import maps gained widespread browser support first in 2023. To obtain support prior to that it was necessary to use an external library like SystemJS, which adds some overhead comparison to the browser-native option.
Why bother with runtime modules?
I admit that the examples above are quite trivial, so you might ask what the benefits of all this are. For a typical monolithic frontend app they are probably insignificant. When there are multiple teams working within the same application however, it can be tempting to split the codebase up in order for teams to be less dependent on each others work.
Imagine you split the codebase into one library for each team and import them into the joint application when building for production. This means that in order to get a tiny fix for the codebase of team A into production, the release pipeline will have to build and run all of the teams' code. As the number of teams and the complexity of the application increase, this can turn really complicated since the coupling removed by developing independently is reintroduced in the release process. If you ever worked in this setting and had to get two PRs in different libraries through in order get a new feature into production you know what I'm talking about… This problem isn't unique to frontend development, I have experienced it when working on a mobile app and it is a known problem for microservices too.
The solution is to not integrate at build time at all. This is where runtime modules can be useful for frontend applications. Instead of bundling in the build step, the modules or microfrontends import or call each other only at runtime.