How JavaScript Is Finally Improving the Module Experience

JavaScript used to be seen as a language where developers could write code quickly, but it was not necessarily suitable for teams of developers writing code at scale for large applications. One reason was that, until relatively recently, it didn’t have strong module support natively.

Before there was an official standard format for packaging chunks of JavaScript code to be used over and over again, developers used tools like Webpack, Babel and CommonJS (CJS). Introduced back in ECMAScript 6, ECMAScript Modules (ESM for short) have definite advantages: Once modern browsers started supporting them broadly by 2018, the browser could take over optimizing module loading, which is more efficient than the client-side processing and round trips required if you’re using a framework or library.

See also: 5 Ways JavaScript Is Improving Modules for Developers

But even after over a decade of work, ESM still doesn’t include all the features and nuances of CJS modules, especially for developers creating tools like bundlers.

“There are areas where ES modules are not as powerful and as easy to use as the previous system,” explained Igalia engineer and maintainer of the popular Babel transpiler Nicolò Ribaudo.

It’s easier to write bundlers using CommonJS than ESM.

It’s easier to write bundlers using CJS than ESM: Webpack continues to compile code to CJS internally, for example. “If you want to rely on pure ESM semantics, you have to rename all the variables, you have to manually create the namespace objects and it’s not even possible to bundle everything. If two modules both use top-level await, there’s no way they can be compiled.”

Babel has stayed on CJS until now because that allows deferring loading modules until they’re needed for performance: While that’s possible with ESM, it has much worse ergonomics. Although Node.JS users have been able to use ESM in their project for some time, Node 22 is still adding support for some ESM features to simplify migration.

“Just migrating from CommonJS is hard,” Ribaudo said.

Putting Modules Back in Harmony

To address these gaps and generally make ES modules work better for developers, a group of related proposals known collectively as “module harmony” is slowly working its way through the standards process.

The module harmony name is also a reminder that while the various proposals may look disparate, they’re all about improving the experience of working with modules in JavaScript.

The improvements are specifically aimed at developers of tools like bundlers who haven’t been able to migrate to ESM, so when the new features become part of the language, most developers will get the benefits without needing to make changes to their code.

The term “module harmony” is something of a play on words. Not only is it a reference to harmonizing the previous and current options for modules in JavaScript, it’s also a nod to the fact that Harmony was both the codename for ECMAScript 6 and the name TC39 uses for its standardization process — which is what restarted regular development of the JavaScript language with annual releases beginning with ECMAScript 2015 (as ECMAScript 6 is officially known). That’s why ES modules are sometimes referred to as “Harmony modules.”

Plus, there are between six and nine different proposals for module improvements in JavaScript under the module harmony umbrella, all unlocking new capabilities in ESM and all moving through standardization at their own rate. In fact, there are so many proposals that we’ll be looking at them in two articles.

“People would say, ‘Hey, there’s all these module proposals, are you all talking to each other?’ And the answer is ‘Yes, we’re talking to each other!'” explained Daniel Ehrenberg, Ecma vice president and Bloomberg software engineer, who’s working on several of the proposals.

module harmony layering

How the different module harmony proposals fit together; via TC39 presentation.

So the module harmony name is also a reminder that while the various proposals may look disparate, they’re all about improving the experience of working with modules in JavaScript.

“We’ve had a very long history of modules in JavaScript, and it’s been a progressive evolution,” said Fastly engineer Guy Bedford, who’s involved in many of the module harmony proposals. This is the familiar process in JavaScript “where people rush ahead and build things, taking the shortest path to solve their problems, and standards are a much slower process following along.”

“What we’re seeing with the module harmony effort is filling in the use cases that, very boringly and bluntly, get us back to parity with what we had with CommonJS, but with something that’s actually a first class, native browser module system.”

While ESM provides very straightforward structuring of code, developers want extra functionality from modules, Bedford suggested. “Things like lazy loading and optimization and virtualization, and the ability to write your instrumentation and mocks, and supporting all the features that bundlers need to bundle code. These are all standard things we do every day, but doing them in a first-class standards-compliant way is surprisingly difficult.”

ES modules do have advantages: “There were immediate benefits from the get-go,” Bedford pointed out. “We’ve seen massive adoption and massive improvements to web development. What we’re adding here is just the cherry on the top: getting the last of the advanced use cases.”

“Our goal with module harmony is to make sure that ESM is powerful enough that CJS will not be needed at all.”
— Nicolò Ribaudo, Igalia engineer

Kris Kowal, the original creator of CommonJS, described module harmony as “about completing a design that was always intended to go at least this far.”

“When I proposed CommonJS, the intention was to create a way people could express JavaScript that could be shared between projects without coupling them to specific frameworks,” he said.

At the time, frameworks like Dojo and jQuery had their own plugin systems and developers writing modular code had to choose which framework to target.

“I wanted to create a forward-looking way to express modules that could easily be transitioned into a proper system when a proper system may exist,” said Kowal.

“Our goal with module harmony is to make sure that ESM is powerful enough that CJS will not be needed at all,” agreed Ribaudo, who is also championing several of the proposals.

It might seem like a frustrating backwards step to slowly rebuild functionality developers already had with CJS in ESM, but Bedford suggested thinking of it as creating a new foundation: “Building that baseline allows new efforts in future to go in a new direction.”

Interoperability Between JavaScript and WebAssembly

One of the module harmony proposals that has made the most progress is Source Phase Imports. It’s already reached stage 3 with implementation work underway, because being able to customize the way modules are loaded, linked and executed — by importing an object that presents the source of the model rather than using the module directly — will deliver more seamless interoperability between JavaScript and WebAssembly.

Using WebAssembly and JavaScript together is currently a complex process, Ribaudo explained: “It’s not obvious how to fetch the model and prepare it so it’s ready to run.”

“This isn’t a feature most developers will use directly, but tools that write glue code to help developers import JavaScript code into WebAssembly can use it to improve security.”
— Nicolò Ribaudo, Igalia engineer

“Also, fetching something doesn’t follow the same content security policies as normal imports, so basically the only way you have to fetch and evaluate WebAssembly modules is to significantly relax your security policies. Because you can pass JavaScript functions to your modules, you might accidentally expose new capabilities.”

The proposal allows developers to apply JavaScript-style content security policies as a way of restricting what code can run, because the new object includes the original source URL. “You can say I only want my application to be able to load and run WebAssembly code from these two domains and not code loaded from any other domain.” It also enables static analysis, determining which Wasm modules are being executed in the same way that’s done for JavaScript modules.

“This isn’t a feature most developers will use directly, but tools that write glue code to help developers import JavaScript code into WebAssembly can use it to improve security” he suggested.

The proposal is similar to the way WebAssembly allows you to create multiple instances of a module, Bedford pointed out, and relates to another module harmony proposal called Compartments that will provide a mechanism for more finer-grained isolation within JavaScript. That’s a fairly major attempt to standardize virtualization primitives, which we’ll look at in more detail in future.

“What we call virtualization in JavaScript is just instantiation: Currently when we load a module, you load it once. There’s only one of those modules in your entire application, and any state it has is state that’s going to exist for the lifetime of the application.”

Source Phase Imports will be a building block for virtualization that works more like the WebAssembly option: “The ability to take the abstract representation of the module and create multiple instances of it, maybe with different imports, maybe with different kinds of state internally.”

This allows for more flexibility in JavaScript — for example, enabling userland loaders — but also offers better integration and more ergonomic use of WebAssembly in JavaScript.

“When you use the source phase to import from WebAssembly, you get WebAssembly’s already-existing high-level modules, which can be multiply instantiated,” Bedford explained. Work has already started on implementing this in the V8 JavaScript engine, which should lead to more easily and portably shipping Wasm within JavaScript toolchains.

While Source Phase Imports might sound like a relatively limited advance, the concept of separating the different stages (or phases) for loading modules that affect how a module can be used is key to many of the other module harmony proposals.

There are actually five different stages in the module pipeline:

  1. Resolving the network route to the module so the browser knows where it is.
  2. Fetching (and possibly compiling) the module.
  3. The “source” phase of retrieving and attaching the context of how it will be executed and where its dependencies need to be loaded from.
  4. Linking those loaded dependencies and binding any imports.
  5. Finally, evaluation where all loaded modules are executed.

The linking and evaluation stages are referred to together as the “instance” phase (because there’s now an instance of the module).

The five phases of importing modules

The five phases of importing modules; via TC39 presentation.

Save It for Later

Source Phase Imports lets developers work with a module that has been fetched with its context before the module code is executed, but still rely on guarantees that static analysis shows what code will be executed and get better ergonomics, tooling support and security.

Other proposals — Module Declarations, Module Expressions (check back soon for more details on these two) and deferred module imports — also rely on these different phases of the module pipeline, like deferring the final evaluation stage until you actually need a property of the module.

“With this proposal, you can defer that initialization work until the module is needed, while still loading the module and parsing it and having it ready to go in the module system.”
— Guy Bedford, Fastly engineer

The goal of deferred imports is to improve startup time for web pages and Node applications that include a lot of JavaScript that won’t actually be called until later (perhaps if the user clicks a button) or maybe not at all, Bedford explained.

“When you import a module, you have to execute all its dependencies up front. When you go to a web page, and it’s loading a whole bunch of modules, you’re initializing all those modules at once, even if you’re not using them. With this proposal, you can defer that initialization work until the module is needed, while still loading the module and parsing it and having it ready to go in the module system.”

That’s a familiar technique in CommonJS (it’s also available in other languages, like Go, Python, Ruby and Swift). “When your initialization slows down, you move your require inside the function, and this is something very similar for native modules.” Many bundlers that haven’t migrated off CJS yet rely on this speedup.

This is much less complex than the major refactoring required to use asynchronous code and dynamic import, currently the only option for lazy loading JavaScript modules.

Dynamic import “lets you separate out different pieces of code that can be loaded at different times,” Ehrenberg explained, which is why many bundlers implement it. “But that only works if you’re in a context where you can afford to go to the network and load something slow. In other cases, you’re in the middle of a deep computation and everything is synchronous, and you want to be able to respond quickly.” That’s where deferred import comes in: “It allows certain cases of delayed execution that are not possible otherwise.”

Modules that use top-level await (which adds asynchronous loading logic) can’t defer evaluation and will be executed at app startup. Although the implications of that need to be clear before the proposal can move forward from stage 2 (which it reached in 2023), Ehrenberg’s experience at Bloomberg, which has been using its own equivalents of both top-level await and deferred imports in JavaScript for some time, suggests that rather than being a bug, “this is the natural way for them to compose.”

The speedup won’t be as great as within Node.js, because for server-side code the module file is stored where the code executes, but a browser has to load the file from somewhere else.

“Loading is still a significant part of the initial release at startup time,” Ribaudo admitted, but deferred import makes it easier to add some performance improvements without asking developers to refactor code.

“If you’re trying to improve performance in a specific place, having to refactor your whole application to support a function call asynchronously is not ideal. We’re giving developers a tool to choose their tradeoff between ergonomics and performance. Right now, you have bad performance with optimal ergonomics or very good performance with less ideal ergonomics, and we’re adding a point in between that still keeps the good ergonomics while improving performance — even if not as much as it could be.”

Mozilla uses its own version of lazy module loading in the Firefox front end: In its internal code, loading and parsing a module is responsible for around half the startup time, but half is taken up by evaluation that deferred import can delay until it’s actually needed. Other experiments showed smaller improvements, cutting the time taken on JavaScript modules by 20%: on a page taking more than a second to load, that was a 6% improvement. That’s still a significant improvement for code changes you can do quickly and easily with the new import defer syntax.

“The number-one thing for an application is how quickly can you start interacting with it?” Bedford pointed out. “If we can see real performance numbers out of this, I think it’ll be really compelling to be able to say that we can speed up your application.”

Make Workers Easier to Work With

Building on that, Module Phase Imports also promises performance improvements, using the same source phase and even the same syntax as Source Phase Import to get similar benefits, but for workers rather than Wasm modules.

Having reached stage 1 earlier this year, it’s just about to be considered for stage 2 and promises a better way to load workers that makes code more ergonomic and makes it easier to handle static analysis of the relationships between modules.

That’s currently difficult when modules are loaded in workers and becomes even more opaque for libraries, because not all build tools work well if your code uses workers. This discourages library authors from taking advantage of the speedup workers can offer to avoid adding build complexity for their users.

Better support for workers in developer tools and JavaScript runtimes should end up making JavaScript applications faster because if workers are easier to work with, developers and library authors are more likely to use them to offload computationally demanding tasks.

Divide and Conquer

One of the earliest attempts at what became module harmony, Asset References, was intended to work at the initial “resolve” phase of the module pipeline, when you can refer to a module and pass it around like a handle without loading or initializing it, Ribaudo explained.

The deliberately slow pace of JavaScript standardization is because the default is not to add new features to the language until they’ve proven useful.

“You can statically declare that at some point, you will make use of an asset. That might be a module, it might be a CSS file or an image — a dataset that has not been loaded yet but your bundler can bundle it knowing [that] at some point you will use it.”

It sounds useful, but as work on the different proposals has progressed, Asset References hasn’t had a lot of attention because the other proposals cover similar issues and may solve more of the problems. In fact, it’s been at stage 1 since 2018 and may no longer be needed because of the progress in other areas. That’s not a waste of time, though. The different approaches taken in the other module harmony proposals all have different benefits and drawbacks, and working through which of them best solves the problem is a key part of the standardization process.

This reflects the deliberately slow pace of JavaScript standardization, where the default is not to add new features to the language until they’ve proven useful.

The module harmony suite of proposals does include some more ambitious approaches, particularly to security for modules. In a follow-up post, we’ll dig into what that means and cover other proposals like Module Expressions and Module Declarations that show how new language features evolve as they make it through the standards process.

Part 2 of this post: 5 Ways JavaScript Is Improving Modules for Developers

Group Created with Sketch.

 

 

 

 

Top