📑 Contents The Waterfall Problem Code Splitting modulepreload Tag Integrity Attribute Polyfill (ES Module Shims) Lazy Modules Problem Future Proposals JSPM Generator Mental Model References

1. The Waterfall Problem

When a browser runs an ES module, it doesn't know what other modules it needs until it starts parsing. This creates a waterfall — each module only gets discovered after its parent finishes loading.

WITHOUT preloading — sequential waterfall: t=0ms Browser parses HTML, finds <script type="module" src="app.js"> t=50ms app.js loads → browser sees: import './dependency.js' t=100ms dependency.js loads → browser sees: import './library.js' t=150ms library.js loads → all done Total: ~150ms+ just for discovery

The deeper your module graph (more levels of imports), the worse the delay. This is the core problem that modulepreload solves.

⚠️

A browser cannot fetch library.js until it has already fetched and parsed both app.js and dependency.js. There is no way to parallelize this without telling the browser in advance.

2. Code Splitting (Background)

Before preloading, bundlers like esbuild and RollupJS address the waterfall differently — by merging modules that are always loaded together into a single chunk. Fewer files = fewer round trips.

Approach Mechanism Trade-off
No bundling Each module is a separate file BAD Many round trips, deep waterfall
Full bundle All code in one big file OK No waterfall, but can't cache shared code
Code splitting Co-loaded modules merged into chunks GOOD Fewer files + shared chunks are cacheable
Code splitting + preload Chunks loaded in parallel upfront BEST No waterfall + cacheable + secure

Code splitting gets you halfway there. Preloading finishes the job.

3. The modulepreload Tag

The <link rel="modulepreload"> tag tells the browser: "I know you'll need this module — fetch it now, don't wait." All listed modules are fetched in parallel before any of them execute.

<!-- Without preload: browser discovers these one by one -->
<script type="module" src="/src/app.js"></script>

<!-- With preload: browser fetches all 3 in parallel at t=0 -->
<link rel="modulepreload" href="/src/app.js" />
<link rel="modulepreload" href="/src/dependency.js" />
<link rel="modulepreload" href="/src/library.js" />
<script type="module" src="/src/app.js"></script>
WITH modulepreload — parallel loading: t=0ms Browser sees all 3 preload tags → fetches all 3 at once t=50ms All 3 files done → execution begins immediately Total: ~50ms (only 1 round trip)

You as the developer (or your bundler) know the full module graph ahead of time. You inject the preload tags into the HTML. The browser then fetches everything in one go.

How It Differs from Regular Preload

<link rel="preload"> is for generic resources. modulepreload is specific to ES modules — the browser processes it through the module system (parse + compile), so there's no "double parse" when the module is actually imported.

4. The Integrity Attribute

The integrity attribute lets you specify a cryptographic hash of the file. The browser verifies the downloaded file matches before executing it. If someone tampers with the file on a CDN, the browser refuses to run it.

<link rel="modulepreload"
  href="/src/app.js"
  integrity="sha384-Oe38ELlp8iio2hRyQiz2P4Drqc+ztA7jb7lONj7H3Cq+W88bloPxoZzuk6bHBHZv" />

<link rel="modulepreload"
  href="/src/dependency.js"
  integrity="sha384-abc123..." />
🔐

Why it matters: This is called Subresource Integrity (SRI). It protects against supply-chain attacks — if a CDN is compromised and serves a modified file, the hash won't match and the browser blocks execution entirely.

What Makes modulepreload Special for Security

modulepreload is currently the only mechanism that lets you attach integrity checks to every module in your dependency chain. A normal <script type="module"> tag can have an integrity attribute, but its dynamically imported dependencies cannot — unless they are also preloaded.

Method Integrity Support
<script integrity="..."> YES — entry point only
import './mod.js' (static import) NO — no place to put hash
import('./mod.js') (dynamic import) NO — no place to put hash
<link rel="modulepreload" integrity="..."> YES — any module in the graph

5. The Polyfill — ES Module Shims

At the time of writing (2021), modulepreload only worked in Chromium browsers (Chrome, Edge). Firefox and Safari didn't support it. So a polyfill was needed.

📦

The polyfill is included in ES Module Shims v0.12.1+. It intercepts <link rel="modulepreload"> tags and fetches them using the fetch() API with integrity support, making it work in all browsers.

How the Polyfill Works

  1. 1
    Scans the page for <link rel="modulepreload"> tags
  2. 2
    Fetches each one using fetch(url, { integrity, credentials, referrerPolicy })
  3. 3
    Reads the response fully via .arrayBuffer() — this prevents a double-fetch race condition where the browser might fetch the same file twice
  4. 4
    Uses a MutationObserver to watch for dynamically added preload tags too
  5. 5
    Stores the fetched module in cache so when the <script type="module"> tag runs, it uses the cached version

Usage

<!-- Load polyfill FIRST, before any modulepreload tags -->
<script async src="https://ga.jspm.io/npm:es-module-shims@1.x/dist/es-module-shims.js"></script>

<link rel="modulepreload" href="/src/app.js" integrity="sha384-..." />
<link rel="modulepreload" href="/src/dependency.js" integrity="sha384-..." />
<script type="module" src="/src/app.js"></script>

6. The Lazy Module Problem

Everything above works great for modules that load at startup. But modern apps use lazy loading — loading code only when the user navigates to a specific page or clicks a button.

// This module is loaded lazily on user action
button.addEventListener('click', async () => {
  const { doSomething } = await import('./heavy-feature.js')
  doSomething()
})

You can't put a modulepreload tag in your HTML for heavy-feature.js because that would defeat the purpose of lazy loading — the file would be fetched immediately on page load.

This creates a gap: lazy-loaded modules cannot get integrity protection without preloading them upfront. And preloading them upfront kills the performance benefit of lazy loading.

The Core Tension

Goal Approach Problem
Performance Lazy load (don't preload) No integrity verification possible
Security Preload with integrity hash Kills lazy-loading performance benefit

This is an unsolved problem at the time of the article — and motivates several future proposals.

7. Future Proposals

Several ideas are being explored to solve the lazy module + integrity problem. None have full browser support yet.

A. Import Assertions (Inline Integrity)

// Proposed syntax — hash inline with the import
import './heavy-feature.js' assert { integrity: 'sha384-...' }
⚠️

Problem: This breaks import maps. Import maps let you remap bare specifiers to URLs — if the hash is on the import statement, different parts of the app using different hashes for the same module causes conflicts. The caching and deduplication benefits of import maps are lost.

B. Import Map Integrity

// Hash lives in the import map, not the import statement
<script type="importmap">
{
  "integrity": {
    "/src/heavy-feature.js": "sha384-..."
  },
  "imports": {
    "heavy-feature": "/src/heavy-feature.js"
  }
}
</script>
💡

Why this is better: The import map acts as a single source of truth for all module hashes. Any import of the module — from any part of the app — uses the same hash. No duplication, no conflict. This proposal is the most promising but was not yet browser-adopted at time of writing.

C. Lazy Preloads

<!-- Conceptual: attach integrity without triggering an actual fetch -->
<link rel="modulepreload" href="/src/heavy.js" integrity="sha384-..." lazy />

The idea: register the integrity hash for a module without actually fetching it yet. When the browser eventually loads it (via dynamic import), it checks against the pre-registered hash. This is still a design concept — not a real spec yet.

D. Web Bundles

A separate proposal to bundle an entire site into a single signed bundle. Integrity is handled at the bundle level. However, verification involves hashing the entire bundle — expensive for large apps — and integrity for lazy modules within a bundle is considered a follow-up problem.

8. JSPM Generator

JSPM (JavaScript Package Manager) is a tool that can automatically generate modulepreload tags with integrity hashes by tracing your module graph statically.

What It Does

  • Takes your entry point module(s) as input
  • Traces all static imports recursively
  • Generates <link rel="modulepreload"> tags with correct integrity hashes for every discovered module
  • Outputs ready-to-paste HTML

Online Generator

The JSPM Online Generator (at generator.jspm.io) lets you toggle preload and integrity generation via checkboxes. Useful for quickly getting the right tags for a given package or module graph.

🛠

Practical tip: In a real project, your build tool (Vite, Rollup, esbuild) should generate these tags automatically during production build. Manually managing integrity hashes for every module is not sustainable. Look for build plugins that emit modulepreload tags into your HTML.

Mental Model — Put It All Together

STEP 1 — BUILD TIME Bundler does code splitting → produces: app.js, dependency-chunk.js, library-chunk.js Bundler computes sha384 hash for each file Bundler injects into HTML: <link rel="modulepreload" href="app.js" integrity="sha384-abc" /> <link rel="modulepreload" href="dependency-chunk.js" integrity="sha384-def" /> <link rel="modulepreload" href="library-chunk.js" integrity="sha384-ghi" /> <script type="module" src="app.js"></script> STEP 2 — BROWSER (at page load) Reads all 3 modulepreload tags → fetches all 3 files in parallel Verifies each file hash before executing → All 3 loaded in 1 round trip, all verified STEP 3 — EXECUTION <script type="module"> runs → imports are already in cache → No re-fetch, no waterfall, no delay
🎯

The key insight: modulepreload gives you both performance (parallel loading, no waterfall) and security (hash verification) at the same time. It's not a trade-off — you get both. The only remaining gap is lazy-loaded modules, which is an active area of standards work.

Quick Cheat Sheet

Concept One-liner
Waterfall Modules discovered one by one as each parent loads — adds latency
Code splitting Merge co-loaded modules into chunks to reduce file count
modulepreload Tell browser all needed modules upfront → parallel fetch, no waterfall
integrity hash Browser verifies file matches expected hash before running — blocks tampered code
ES Module Shims Polyfill that makes modulepreload + integrity work in non-Chromium browsers
Lazy module problem Dynamic imports can't get integrity unless preloaded, which defeats the purpose
Import map integrity Proposed fix: store all hashes in the import map as a single source of truth

References