Preventing Waterfall Requests with Dynamic Import Maps
Modern SPAs suffer a specific failure mode during route transitions: the browser discovers dynamic chunks one at a time, triggering a cascade of sequential HTTP requests that stalls the main thread. On a route with three levels of nested import() calls and an average 40 ms TTFB per chunk, the user waits 120 ms of pure latency before the route’s JavaScript even begins executing — and that penalty compounds with route depth.
This page tackles that failure mode in full detail. It sits within the Dynamic Import Patterns for On-Demand Loading cluster, which is itself part of the broader Route-Based Code Splitting & Dynamic Import Strategies guide.
Error Signature: What a Module Waterfall Looks Like
Open Chrome DevTools → Network → filter by JS. Trigger a route transition. A waterfall problem shows these three signals simultaneously:
- Non-overlapping request bars. Chunk B’s
fetchStarttimestamp is greater than Chunk A’sresponseEnd. Nothing is parallel. - Late
DOMContentLoaded. The main thread remains blocked on nested chunk execution. Interactive elements appear long after the network is idle. - Elevated TTFB on secondary chunks. The second and third chunks show a TTFB that includes the previous chunk’s full parse-and-execute cycle, not just network latency.
The console diagnostic below quantifies the gap:
const jsResources = performance.getEntriesByType('resource')
.filter(r => r.name.endsWith('.js') && r.initiatorType === 'script');
const deltas = jsResources.map((r, i, arr) =>
i === 0 ? 0 : r.fetchStart - arr[i - 1].fetchStart
);
console.table(deltas); // Target: every value < 50 msIf any delta exceeds 50 ms, you have a sequential resolution chain that import maps can eliminate.
Root Cause: Sequential Runtime Discovery
Browsers resolve import() calls lazily. Each call returns a Promise, and the runtime does not know which additional chunks a freshly-fetched module will need until it has been parsed. The dependency graph that the bundler computed at build time is not communicated to the browser’s native module loader — so the loader must discover it sequentially at runtime.
Without import maps — sequential discovery:
┌─────────────────────────────────────────────────────────────────┐
│ Request: entry.js → fetch → parse → discover chunk-a.js │
│ chunk-a.js → fetch → parse → discover chunk-b.js │
│ chunk-b.js → fetch → parse → done │
│ Total wall time: 3 × (TTFB + transfer + parse) │
└─────────────────────────────────────────────────────────────────┘
With import maps — parallel resolution:
┌─────────────────────────────────────────────────────────────────┐
│ Browser reads importmap before any fetch │
│ Requests entry.js + chunk-a.js + chunk-b.js simultaneously │
│ Total wall time: 1 × (TTFB + transfer + parse) │
└─────────────────────────────────────────────────────────────────┘
The native <script type="importmap"> element tells the browser the complete specifier-to-URL mapping before any module fetch begins. When entry.js is parsed and encounters import('./chunk-a.js'), the browser already holds the resolved URL — it can initiate the fetch immediately, in parallel with any other imports the parser discovers in the same parse cycle.
The diagram below shows how request timing changes:
Exact Config/CLI Fix: Vite 5 and Webpack 5
Step 1 — Generate the build manifest
# Vite 5 — writes dist/.vite/manifest.json
npx vite build --manifest
# Webpack 5 — writes stats.json for post-processing
npx webpack --json=stats.jsonStep 2 — Inject <script type="importmap"> at build time
Vite 5 (vite.config.js):
// vite.config.js — Vite 5
import { defineConfig } from 'vite';
import { readFileSync } from 'fs';
export default defineConfig({
build: {
manifest: true, // Required: generates dist/.vite/manifest.json
},
plugins: [
{
name: 'inject-importmap',
transformIndexHtml: {
order: 'post', // Runs after manifest is written to dist/
handler(html) {
let manifest;
try {
manifest = JSON.parse(
readFileSync('dist/.vite/manifest.json', 'utf-8')
);
} catch {
return html; // Dev mode: manifest not present yet
}
const imports = Object.fromEntries(
Object.entries(manifest)
.filter(([, val]) => typeof val === 'object' && val !== null && 'file' in val)
.map(([key, val]) => [key, `/${val.file}`])
);
const importMapTag =
`<script type="importmap">${JSON.stringify({ imports })}<\/script>`;
return html.replace('</head>', `${importMapTag}</head>`);
}
}
}
]
});Webpack 5 (webpack.config.js):
Import maps are a browser-native feature; Webpack has no built-in output.importMap option. Write a small plugin that reads the emitted asset list and exposes it to HtmlWebpackPlugin’s template:
// webpack.config.js — Webpack 5
const HtmlWebpackPlugin = require('html-webpack-plugin');
class ImportMapPlugin {
apply(compiler) {
compiler.hooks.emit.tapAsync('ImportMapPlugin', (compilation, callback) => {
const imports = {};
for (const asset of compilation.getAssets()) {
if (asset.name.endsWith('.js')) {
// Map source specifier → hashed output URL
imports[`/src/${asset.name}`] = `/assets/${asset.name}`;
}
}
compilation.importMapJson = JSON.stringify({ imports });
callback();
});
}
}
module.exports = {
output: { publicPath: '/assets/' },
plugins: [
new ImportMapPlugin(),
new HtmlWebpackPlugin({
templateParameters: (compilation) => ({
importMap: compilation.importMapJson || '{}'
}),
// src/index.html must include:
// <script type="importmap"><%= importMap %></script>
template: 'src/index.html'
})
]
};Step 3 — Flatten the chunk graph
Co-locating sibling dependencies eliminates runtime nested-import discovery even before the import map resolves them. Pair Step 2 with explicit chunk grouping.
Webpack 5:
// webpack.config.js — Webpack 5
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
maxAsyncRequests: 10, // Allow up to 10 parallel requests per route
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
};Vite 5:
// vite.config.js — Vite 5
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) return 'vendor';
}
}
}
}
});This is closely related to configuring Vite manualChunks for vendor isolation, which covers the full range of manual grouping strategies.
Step 4 — Disable the Vite modulePreload polyfill
Vite injects a module-preload polyfill by default. With native import maps active, this polyfill adds a redundant resolution layer. Disable it when your browser baseline supports import maps natively (Chrome 89+, Edge 89+, Safari 16.4+, Firefox 108+):
// vite.config.js — Vite 5
import { defineConfig } from 'vite';
export default defineConfig({
build: {
modulePreload: { polyfill: false }
}
});Fallback for older browsers
For Safari < 16.4 or Firefox < 108, inject es-module-shims before the import map script:
<!-- index.html — load shim before the import map -->
<script async src="/es-module-shims.js"></script>
<script type="importmap">{ "imports": { ... } }</script>
<script type="module" src="/app-entry.js"></script>es-module-shims intercepts import() calls and resolves them against the injected import map, providing identical parallel-resolution behaviour on browsers that pre-date native support.
Step-by-Step Verification
1. Console delta check
Run after triggering a route transition:
const js = performance.getEntriesByType('resource')
.filter(r => r.name.endsWith('.js') && r.initiatorType === 'script');
const maxDelta = Math.max(
...js.map((r, i, arr) => i === 0 ? 0 : r.fetchStart - arr[i - 1].fetchStart)
);
console.log(`Max fetchStart delta: ${maxDelta.toFixed(1)} ms`);
// Target: < 50 ms — confirms parallel resolution2. Custom performance mark
Wrap the dynamic import to measure end-to-end execution latency before and after the change:
performance.mark('import-start');
const mod = await import('./heavy-module.js');
performance.mark('module-loaded');
performance.measure('import-execution', 'import-start', 'module-loaded');
const [entry] = performance.getEntriesByName('import-execution');
console.log(`Import execution: ${entry.duration.toFixed(1)} ms`);
// Expected drop: 40–70% on routes with 3+ async chunks3. Lighthouse CI audit
npx lhci autorun --config=lighthouserc.jsonParse the JSON report and assert both conditions:
// lighthouserc-assert.js
const report = require('./lhci-report.json');
const audits = report.audits;
console.assert(
audits['avoid-chaining-critical-requests'].score === 1,
'Critical request chaining still detected'
);
console.assert(
audits['network-requests'].details.items
.filter(i => i.url.endsWith('.js'))
.every((item, idx, arr) =>
idx === 0 || item.startTime - arr[idx - 1].startTime < 50
),
'Chunk fetches are not parallel'
);4. Network waterfall visual confirmation
In DevTools Network, trigger the route transition and look for:
- Green parallel bars for all chunk requests, starting within 5 ms of each other.
- HTTP/2 multiplexing (Protocol column shows
h2) — a prerequisite for parallel fetches to share one connection. - No staircase pattern in the waterfall timeline.
Edge Cases and Gotchas
Import map specifier mismatch
The most common failure is a key format mismatch. The import map key must exactly match the string passed to import(). If your source uses bare specifiers (import('utils/format')) but the map key is src/utils/format.js, the browser will not match them and will fall back to sequential resolution — silently, with no console error in all browsers.
Fix: Normalise specifier format at the plugin level. Either rewrite source specifiers to use path-relative forms (import('./utils/format.js')) or map both bare and path variants in the import map.
CSP blocking inline importmap scripts
If your Content Security Policy uses script-src without 'unsafe-inline' or a nonce, the inline <script type="importmap"> block will be blocked.
Fix: Add the same nonce you apply to other inline scripts to the import map script tag, or use a script-src-elem hash. Most frameworks that handle nonces (Next.js, SvelteKit) need a small plugin hook to include the import map tag in nonce scope.
Dynamic specifiers at runtime
Import maps resolve static string specifiers. Code patterns like:
const name = 'chart';
const mod = await import(`./widgets/${name}.js`);produce specifiers that cannot be pre-registered in an import map because the string is constructed at runtime. The browser issues a sequential fetch for each resolved path.
Fix: Convert dynamic-string imports to explicit branches or use a preload hint:
// Explicit branch — import map can resolve both specifiers
const mod = name === 'chart'
? await import('./widgets/chart.js')
: await import('./widgets/table.js');Alternatively, pair with <link rel="modulepreload"> to eagerly push the likely candidates, since prefetch/preload strategies handle unknown specifiers that import maps cannot cover.
FAQ
Do import maps work in all browsers?
Native import map support requires Chrome 89+, Edge 89+, Safari 16.4+, and Firefox 108+. For older targets, inject es-module-shims before the <script type="importmap"> tag to polyfill resolution without altering application logic.
Does disabling Vite’s modulePreload polyfill break anything?
Only if you still need to support browsers without native import map support. If your baseline is Safari 16.4+ / Firefox 108+ / Chrome 89+, disabling the polyfill removes a redundant resolution layer. Otherwise keep the polyfill and skip the native importmap injection.
Can I use import maps with React.lazy?
Yes. React.lazy wraps a import() call. The import map intercepts that call at the browser level and resolves the specifier to the hashed output URL, so the Suspense boundary and chunk parallelism work together without any React-specific changes. See how to implement React.lazy with route transitions for the full Suspense integration pattern.
Related
- Dynamic Import Patterns for On-Demand Loading — parent page covering the full range of
import()boundary strategies - Route-Based Code Splitting & Dynamic Import Strategies — top-level guide to chunk boundary architecture
- Prefetch and Preload Strategies for Critical Routes — complementary technique when import maps cannot handle dynamic specifiers
- Configuring Vite manualChunks for Vendor Isolation — chunk-graph flattening that pairs directly with import map injection
- How to Implement React.lazy with Route Transitions — Suspense boundary integration for lazy-loaded routes