Building and delivering frontends at scale

A deep dive into the evolution of frontend build tools and techniques. Understand the challenges of serving large interactive frontends.

Rem · 21 Jan 2023

Updated · 7 October 2023

Share:

Introduction

The web evolved from an imitation of static media to becoming the dominant way of distributing cross-platform software.

With its close ties to the web, the Javascript ecosystem continues to grow as software eats the world.

Managing this rapid organic growth has been messy, and poses unique challenges when delivering large interactive applications.

The web is highly fragmented. There’s different browsers, versions, capabilities, quirks, and bugs, running on a range of devices with wildly different performance characteristics.

Over time we evolved many tools to normalize these differences, optimize production bundles, and provide fast local feedback loops.

The Javascript ecosystem is currently experiencing a resurgence of next-gen build tools, built for speed that address fragmentation fatigue.

Meanwhile, many of these tools are becoming optional with the death of IE and widespread support for ES2015+ in modern browsers.

In this post we’ll dive into the evolution of frontend build tooling. We’ll understand some key challenges when delivering large interactive frontends with a focus on Javascript.

By the end, we’ll have a good grasp of the current state of frontend build tools and the problems they address, touching on advanced topics like dynamic bundling.

We’ll begin our journey with the basic building block of any application, the humble module.

A tour of modular Javascript

For a long time there wasn’t a native way to write modular frontend code. The need for modules became self-evident as we started building more complex experiences.

This led to the creation of many non-native module formats over time. Insert comic about competing standards.

Let’s get a quick overview. This will help us contextual the role build tools have come to play.

IIFE - Immediately Invoked Function Expression

Scripts share the global namespace, so even if we split our code into different files, we still risk collisions described in the evolution of scalable CSS.

IIFE’s came about as a simple way to create scopes with some level of isolation.

;(function () {
  // variables defined in here are private
  // encapsulated in the function's closure
})()

CJS - Common JS

Eventually Nodejs entered the scene with CommonJS - one of the first attempts to standardize modules outside the browser.

const { foo, bar } = require('./baz')
class MyClass {}
module.exports = MyClass

A couple things to note that make this unfriendly in browser environments:

  1. Calls to require load and evaluate modules synchronously from the file system. Makes sense locally or on a server. However browsers rely on URLs to load code over a network.

  2. The import path to require can take dynamic strings. This makes it hard to analyze a codebase and know what modules are used for certain, making it hard to drop unused code from browser bundles.

Despite this, we still wanted to reuse Javascript modules across runtimes. Early bundlers like Browserify came about that concatenated CJS modules together into a single massive file that could be sent to the browser.

AMD - Asynchronous Module Definition

Having a module format for the server was great, but we needed something efficient for browsers.

AMD attempted to standardize a browser module format, and solve the problem of parallelized async loading with explicit dependency ordering.

In the early days of the frontend backend split, many MVC frontend frameworks often used AMD, which relied on a runtime loader called RequireJS.

// define takes a dependency array and a factory function
define('myModule', ['dep1', 'dep2'], function (dep1, dep2) {
  // module defined by return value
  return function () {}
})

UMD - Universal Module Definition

UMD was created in 2013 to work in multiple environments. They are implemented with a wrapping IIFE that detects the runtime by checking for the presence of specific properties, and uses either AMD or CJS.

This allows us to use modules without worrying about compatibility issues across different environments.

UMD is typically used as a fallback module when using bundlers like Webpack or Rollup.

For example, you can see how a package like React can be consumed as a UMD module with the wrapping IIFE.

ESM - EcmaScript Modules

Official modules for Javascript came with the ES2015 standard we’d been waiting for but was late to the party.

ES modules give the best of both worlds, learning from previous formats and acknowledging the widening scope of Javascript runtimes.

There’s a lot to unpack about ES modules, so let’s hit the main points:

  • Imports are static allowing for the creation of module dependency graphs. This removes the issues around implicit ordering and identifying unused code. With dynamic imports coming later in the spec.

  • Updates how Javascript is run by enabling strict mode by default, and where variables are scoped to modules. Previously this would refer to the global window object, which now becomes undefined.

    These and other changes like top level await break backward compatibility with a bunch of legacy code loitering on NPM.

  • Changes how Javascript is processed - native modules are implemented in the Javascript engine, so the browser can separate the download, parse, and execution phases.

    These steps typically happen all at once without any breaks in between in non-native modules.

It’s worth noting that the ESM spec doesn’t define how code is loaded, as this depends on the platform.

In the browser, we use script tags with the type="module" attribute. These use defer by default.

Outside the browser, this is via the node modules resolution algorithm that operates on the file system. Next-gen Javascript runtimes like Deno aim to smooth over platform differences and uses a good old URL.

Note we also need a way to tell Node if we want to use ESM modules instead of CJS. This can be done by using a .mjs file extension or setting "type": "module" in package.json.

Despite the temporary split-brain a new standard brings, the arrival of ESM is a significant milestone in the messy organic growth of Javascript into a powerful cross-platform language.

Unbundling the web

Before widespread ESM support, if we wanted to use modern syntax or features, we would need the help of a transpiler and polyfills.

Today browsers are powerful and widespread support for ES2015+ let us write modern Javascript natively.

In earlier times, our toolchain was a simple URL to grab jQuery from a CDN. Now modern CDNs like ESM.sh, Sky pack, JSMPM, and unpkg provide packages on NPM consumed via a URL.

For many sites, this makes much of the build tooling we’ll explore next optional.

One issue is that code like import { html } from 'lit-element' won’t work in a browser. That’s where import maps come in.

Import maps allow us to use bare specifiers (originating from CJS style) like from 'lit-element' in our code, that map to a URL the browser can fetch from:

<script type="importmap">
  {
    "imports": {
      "lit-element": "https://unpkg.com/lit-element?module",
      "lit-html": "https://unpkg.com/lit-html?module"
    }
  }
</script>

This removes the need for complex build tooling for a large class of websites and applications.

In the React ecosystem Deno powered frameworks like Aleph and Ultra allow you to create server-rendered React applications without any build tools.

The web as a compile target

There’s trade-offs between sticking to a platform and embracing it’s restrictions, versus building in an abstraction on top with more flexibility. And what works well in one scenario won’t necessarily work at a larger scale.

Often times the most useful abstractions get absorbed back into the platform. Our old friend jQuery is good example, where today you might not need jQuery.

But as we’ve seen, platforms move much slower (and for good reason) than user-land abstractions. Where many of us have grown accustomed to productivity-boosting technologies like Typescript or JSX.

Today the code we write is heavily compiled, optimized, and transformed by frameworks as to be unrecognizable on the other side. We’ll always have a compilation step to optimize production bundles.

With that in mind, let’s make sense of the current state of frontend build tools by understanding the evolution of how we got here.

The “task runners”

An early wave of frontend tooling was often called “task runners” that orchestrated our various pre-processing tasks.

These were tools like Grunt, and Gulp (and before these RequireJS and Closure Compiler).

We won’t spend much time here. But these guys were important early on for production bundling and handling the concatenation and optimization of files - where manual source order was necessary.

Webpack

Webpack entered the scene roughly when SPAs started to take off, combining bundling and compilation.

Transpilers like Babel combined with Webpack allowed us to use ES2015 syntax, which compiled down to Javascript all browsers could understand. The popularity of React with its javascript-driven JSX also solidified the adoption of this compilation phase.

Compared to manual source file ordering, Webpack takes an entry point and builds a static dependency graph, which ensures things load in the correct order automatically.

It’s extensive ecosystem of plugins allowed us to transform and compile our code enabling a great deal of flexibility and experimentation in how we built things.

This also enabled us to take different strategies depending on the environment.

In development mode, we could see changes we made instantly reflected in the browser. In production, we could serve optimized bundles with deferred chunks loaded on demand. A cost of all this power and flexibility was complex configurations (and pain).

Parcel

Parcel was released in 2017 partially in response to pushing Webpack’s limits at scale.

Builds were getting longer, configurations were getting complex. If you were lucky, at least one team member knew how Webpack was configured, so you didn’t have to mess with it.

In large organizations, slow build times are a major killer for productivity. In this context, Parcel pioneered the zero-config build tool that got out of your way with best practices baked in. While at the same time significantly reducing build times for large frontend projects.

We also started using system-level languages for build tools like Rust and Go, which significantly outperform Node for these types of tasks.

ESbuild

One of esbuilds’s main motivations was to be as fast and lightweight as possible with everything built from scratch in Go. Focussing on doing one thing well.

Esbuild is a high-performance minimalist bundler without many bells and whistles. It aims to do its job as a bundler very well and very fast.

It achieves its speed by heavily utilizing parallelism and being as streamlined as possible.

Rollup

Rollup became popular with library authors for producing optimized bundles, where unused code gets eliminated. In addition to being able to extend it with plugins easily.

Removing unused code is often referred to as “tree-shaking”. Let’s recap how ES modules help with tree-shaking:

  1. Imports are static so a dependency graph can be created, this is an important requirement. As we saw, this is hard to do programmatically with require because it take can dynamic strings as import paths.

  2. ESM allows for multiple exports, which makes it easier to know for sure what consumers of modules use.

    By comparison, CJS and AMD only allow for single exports. The workaround is to export an object with a bunch of properties on it.

    Let’s say we consume the _ package. Even if we only consume a single function like memoize, it’s difficult for bundlers to know for certain which parts of _ we utilize, so everything gets included.

    A subtle gotcha here is that historically transpilers like Babel would transpile our ESM syntax into CommonJS under the hood.

    So it could look like things would be tree-shaken, but we’ve inadvertently increased our bundle size by pulling in everything.

    These subtle foot guns often fly under the radar without proper checks and balances, which we’ll touch on later.

Vite

Vite is a popular build tool that leverages the benefits of native ESM to be very fast.

For local development it uses esbuild with pre-bundled application dependencies. And then serves unbundled application code as native ESM.

This results in fast recompilations by leveraging browser caching for packages that don’t change. Only needing to recompile the file that’s changed, instead of recomputing the entire dependency graph.

For production builds it uses Rollup, which as we’ve seen, makes tree-shaking easy, in addition to lazy loading and common chunk splitting to help optimize browser caching.

Turbopack

Turbopack aims to be the successor to Webpack. Rebuilt from scratch in Rust, it addresses the scaling issues of Webpack on large projects.

Compared to Vite, which leverages native browser functionality for local development, Turbopack takes the bundled approach and highly optimizes the recompilation phase when modules change locally.

At a certain scale, it’s often faster if the browser can receive the code it needs in as few network requests as possible. We’ll touch on this in the next section.

Turbopack also supports compiling React Server Components out of the box and is integrated well into the Vercel ecosystem for React devs on Next.

The challenges of optimal code delivery

There’s many tools we don’t have time to dig into (like Rome, Bun, and Deno that provide unified tool chains).

But let’s step back and solidify our understanding on some key challenges of delivering frontend code.

We’ll start with two ends of the spectrum:

  • Download each individual script

    A benefit of this approach is its simplicity and utilization of the browser cache for files that don’t change.

    At a certain scale we run into issues for large applications with thousands of individual files where the bottleneck quickly becomes the browser’s concurrent request limit.

  • Combine scripts into a single chunk

    This solves the network request overload issue, and is a pretty efficient approach for many small applications.

    We get new scaling problems though:

    1. We must tell the browser to invalidate its cache when we make changes.

      We often use content-hashing to generate a unique identifier based on file contents. So when the file changes, we update the file name with the new identifier and the browser downloads the new version.

      This means users continually re-download and execute the same code every day for a popular application.

    2. We end up serving a bunch of code the end user doesn’t immediately need.

At either end of these two extremes, we run into the issues of efficient network utilization and cache optimization.

In practice, there’s usually a middle ground, and as always that depends on what you are building. But here’s some principles to keep in mind:

  1. When and how do we load code?

    As a general guide to be as fast as possible, we only want to load code that is needed for the current experience and defer everything else.

    Once the current experience has loaded we can pre-emptively load additional stuff behind the scenes, or on user interaction.

  2. How much do we send?

    Code is the most expensive asset we deliver, the best code is often no code. Ideally we want to send the least amount we can get away with. Again this may depend on the specific user flows within a application.

  3. When and how do we execute code?

    Downloading code over the network is one cost, but running that code is the real cost of Javascript. The next section covers this point.

Controlling Javascript execution

We’ve paid the cost of downloading code over the network. A key challenge now is optimizing when our code gets executed by the browser.

Let’s understand the two ends of the spectrum:

  • Immediate execution

    This is typically the default behavior. The browser starts munching through Javascript as soon as it finishes downloading.

    This often results in flooding the single-thread, preventing user input, and executing code that may not be relevant to the user’s experience they landed on.

  • Just in time execution

    This approach defaults to executing code on demand, usually on user interaction.

    The idea here requires us to defer any work that happens at the top level of a module as a side effect of consuming it (like evaluating it’s dependencies). We can use inline-requires to move the cost of importing a module to the first time it’s needed at runtime.

    There’s nuances to this approach, and it can introduce intermittent bottlenecks, where we intermittently flood the system as code executes in response to interactions.

Both forms in their extreme are not optimal, there is a trade-off between TTI and INP.

Balancing the trade-offs

So how do we best follow the principle of deferring unnecessary stuff?

A sweet spot for most applications is a combination of deferring downloads with code-splitting and deferring execution with just-in-time evaluation. But avoiding the intermittent bottlenecks through smart pre-loading and pre-evaluation.

  • Optimistic prefetching

    This is the idea of prefetching code pre-emptively on user interaction, or eargly post page load with an understanding common user journeys.

    A common option is to prefetch during browser idle time.

    This is becoming easier to do with native APIs like the module preload directives and link prefetching.

    It also extends to prefetching data done with fetch via <link href="/myroute" as="fetch" />.

    This can make subsequent page transitions very fast where code and data have been pre-emptively downloaded and cached by the browser.

    One of the benefits of using a meta-framework with nested routing like Remix and many others, is that is that we get these techniques baked into the framework.

    Another example of this idea is in Qwik, which prefetches all relevant code on page load, but with the execution deferred until needed.

  • Pre-evaluating code

    The same strategies also apply to pre-evaluating code that has been deferred from executing after downloading.

    Some native primitives to help achieve this is via APIs like isInputPending and requestIdleCallback.

In the context of server rendered SPAs these optimizations are related to the idea of progressive and/or selective “hydration”.

Progressive hydration is a broader topic for another day, with many challenging problems.

At the risk of going down another rabbit hole, let’s turn our attention to another key element in large projects that lead to bloated bundles and slow runtimes.

Dependency management - avoiding bundle bloat

We have direct dependencies - these are dependencies we explicitly depend in package.json.

And then we have transitive dependencies these are the dependencies of our direct dependencies.

One common problem on large codebases is duplicated dependencies. This means we bundle the same code, but in different versions causing unnecessary bloat.

These are difficult to detect and can slip in unnoticed without having tooling and processes to manage this.

De-duplicator scripts can help, but duplication is hard to avoid for any large project with many dependencies.

Managing many dependencies is complex, and one of the most painful aspects of the Javascript ecosystem. Here are practical principles when it comes to dependency management on large projects:

  • Before adding a new dependency - doing a thorough cost benefit analysis, and understanding what is being brought in all the way down.
  • Bundle size budgets that break builds and shown automatically in pull requests.
  • Ability to analyze bundles to understand exactly what gets sent down the wire.

On top of managing dependencies, the largest frontend projects often have problems serving with multiple versions of code.

This includes running A/B tests and shipping behind feature flags to specific cohorts of users in various locations using different languages.

These problems throw a few spanners in the works against our simple principle of only send what is necessary.

Let’s finish up by exploring the concept of dynamic bundling and module federations, which address some of these problems.

Advanced dynamic bundling

Dynamic bundling is the idea that users get served different code depending on their context.

For example, application user role, experiment group, locale and language settings, browser version, hardware capabilities, etc. Where a static dependency graph compiled at build time is limited.

We briefly touched on this topic in The new wave of Javascript web frameworks on how the biggest companies like Facebook have internal infrastructure that allows them to calculate bundles on the fly using contextual user data.

For highly interactive applications with complex requirements like localization, feature-flagged code, and various user roles, it’s the holy grail of shipping optimal payloads.

It’s also the height of complexity for frontend build tooling, requiring a tight client-server relationship, with few implementations out in the open.

Module federations

Webpack 5 pioneered the idea of module federations. We can think of these as dynamically linked shared libraries. Allowing us to import modules from other independently built bundles at runtime.

They provide dynamic behavior to an otherwise static dependency graph, and in part, what powers an architecture like micro frontends.

Module federations aim to solve a lot of the problems we described above. In addition to things like rolling out large-scale library upgrades, big refactorings of shared modules, or keeping design system components updated to the latest versions.

It’s good to remember with great power comes great responsibility; when things are dynamic, there’s the possibility of creating new problems like unexpected breaking API changes or worse.

Let’s take an example of a large interactive frontend requiring translation in multiple languages.

Module federation would allow you to share translated strings across independent applications, loaded dynamically based on the user’s locale.

With a static dependency graph that most bundlers created, solving this is trickier with many trade-offs.

A common approach is to generate separate bundles for each locale, which can significantly increase build times and add to the complexity of frontend builds.

These are issues only very large frontends face, and the technology is evolving quickly somewhat under the radar.

Conclusion

We covered a lot of ground. Despite the rocky history, and the fragmentation that still exists today, the future of the web is looking more streamlined and standardized.

With new runtimes appearing as edge workers, the need for standardized APIs across all Javascript runtimes has never been more evident, with organizations like WinterCG appearing to fill this need.

The death of IE11, support for ESM and evergreen browsers means the days of managing frontend build tools can come to an end for a large class of websites.

For larger applications, bundling tools have never been faster and more powerful. Which, as we’ve seen, afford many advanced techniques for some of the challenges big frontends face.

As frontend architecture becomes increasingly distributed, many of these concepts are bundled into next-gen frameworks and tools as default best practices.

References and further reading

Want to level up?

Get notified when new content comes out

Feel free to unsubscribe anytime. No spam.