Skip to main content

How the Browser Loads Scripts

· 25 min read
Pere Pages
Software Engineer
The browser building a module graph from script tags and imported files

You add a <script> tag, the page runs your code. Underneath that one line the browser does a surprising amount of work: it decides when to run the script, fetches every file it depends on, figures out what each file is asking for, keeps every loaded module in memory so it runs exactly once, and evaluates them all in the right order. This is a tour of that machinery.

This is the browser-side companion to A Chronological Guide to JavaScript Modules, which tells the history — how the ecosystem got from global scripts to ES Modules across the server and the browser. Here we stay entirely inside the browser and look at the mechanics: how files are loaded, how dependencies are tracked and kept in memory, how a module asks for the other modules it needs, and how the browser orchestrates all of it into running code.

The mental model: a graph the browser builds and keeps

Before any details, hold this picture, because everything else hangs off it.

When the browser loads modern JavaScript, it isn't running a list of files — it's building a graph in memory and then walking it. Starting from an entry point (a <script> tag — there can be several, all feeding one graph), it discovers which files that script needs, fetches them, discovers their needs, and keeps going until it has the whole dependency graph. Every file is fetched and executed once, no matter how many other files import it, because the browser records each one in an in-memory registry called the module map.

So the browser's job splits into a few clear questions, and the rest of this post is one section per question:

Keep that loop in mind: discover → fetch → link → evaluate. We'll build up to it and then take the graph apart in detail.

How a <script> enters the page

The browser reads your HyperText Markup Language (HTML) top to bottom, building the Document Object Model (DOM) as it goes. When the parser reaches a <script> tag, what happens next depends on the tag.

A classic <script> with no special attributes is the disruptive case: the parser stops, fetches the script (if it has a src), runs it to completion, and only then resumes building the page. The script can call document.write and reach into the half-built DOM, so the browser genuinely cannot continue until it finishes. That blocking behavior is the whole reason scripts historically lived at the very bottom of <body> — so the page was already parsed before any script froze it.

That default is almost never what you want today. The next section is how to get out of it.

Controlling order: async, defer, and modules

Two attributes (async and defer) change when an external script runs, and module scripts get the good behavior for free. Here's the whole space in one table:

SetupFetchExecuteOrder keptBlocks parsing
<script src> (classic)Blocks parserImmediately, mid-parseYesYes
<script src async>In parallelAs soon as readyNoOnly to run
<script src defer>In parallelAfter parse, before DOMContentLoadedYesNo
<script type="module">In parallel (CORS)After parse (deferred), deduplicatedYesNo
<script type="module" async>In parallelAs soon as readyNoOnly to run
Inline <script> (no src)ImmediatelyYesYes

Non-blocking Briefly blocks Blocks the parser

The two knobs, plainly:

  • defer — download in parallel with parsing, then run in order, after the page is parsed. Use it when scripts depend on each other or on the DOM.
  • async — download in parallel, then run as soon as each one is ready, in whatever order they finish. Use it for independent scripts (analytics, ads) where order doesn't matter.

The important detail for the rest of this post: <script type="module"> is deferred by default — you never write defer on it. A module never blocks the parser, and it always runs after the document is parsed. async on a module opts back into "run as soon as ready."

One gotcha: async/defer are ignored on inline classic scripts — they only affect external ones with a src. Inline module scripts, though, are deferred like any module.

Which one to reach for:

Notice the module row carries properties the others don't — it's fetched with Cross-Origin Resource Sharing (CORS) and deduplicated so the same file never loads twice. Those aren't facts about when it runs; they fall out of what a module fundamentally is. That's the next section.

What a module script is: a node in the graph

The graph from the top of this post isn't a metaphor — it's exactly what <script type="module"> switches on. A module script isn't "a classic script with extra features"; it's a file that agrees to be a node in the browser's module graph, and every way it differs from a classic script falls straight out of that one job. Take the differences one at a time and none of them is arbitrary.

It's identified by its URL. The module map from the mental model is keyed by resolved address, so a module's identity is its URL — the absolute Uniform Resource Locator (URL) the browser resolves it to (https://site.com/app.js), not the './app.js' you typed. That resolved address is what "a module URL" means: the key the browser files the node under. Two imports that resolve to the same URL are the same node; two that resolve differently are two nodes, even if the files are byte-for-byte identical.

So it runs exactly once. Because the node lives in the map under its URL, the browser fetches, parses, and evaluates it a single time, then hands every later importer the already-evaluated result — a given module URL is loaded once per document, no matter how many other modules import it or how many <script type="module"> tags pull it in. "Runs once" isn't a rule stapled onto modules; it's just what a URL-keyed graph does.

It gets its own scope, because the graph wires files together explicitly. Nodes share values only along the graph's edges — through import/export — so a module has no need to reach the global namespace, and every reason not to. If its top-level variables leaked onto window the way a classic script's top-level var does, two independent modules would clobber each other the instant they picked the same name. So a module keeps its own top-level scope: nothing escapes to window unless you export it, and top-level this is undefined rather than window. Strict mode is on automatically for the same reason — the module system is the clean-slate, explicit-wiring design, and legacy sloppy-mode globals have no place in it.

It's deferred, because the graph must be built before anything can run. A module can't execute the moment the parser meets its tag: the browser first has to fetch that file, read its imports, fetch their files, and keep going until the whole graph is discovered and linked (the next few sections). None of that can happen mid-parse, so — as the ordering section noted — a module script is deferred by default, never blocking the parser and always running after the document is parsed. That's why you never write defer on a module: deferral is inseparable from having to assemble a graph before you can walk it. (async opts back into "run this subtree as soon as it's ready.")

It's fetched with CORS, because the browser reads it as data. A classic script is fetched and handed to the engine to run more or less opaquely. A module has to be inspected — parsed for its import statements and linked to other nodes — so the browser genuinely needs to read its bytes. Across origins that needs the server's say-so: a cross-origin module must arrive with the right CORS headers or the browser refuses it.

And because the map is per-document, this all composes: every <script type="module"> on the page is a separate entry point into one shared graph. Two module tags that both import './util.js' resolve to the same URL, find the same node, and share the one instance. Classic <script>s stay outside the graph entirely — they can't import/export, never appear in the module map, and reach modules only through the global scope and execution order.

That is one idea seen from several sides. A module is a node the browser stores by URL and builds into a graph; its own scope, its once-only evaluation, its deferral, and its CORS fetch are all just what that job requires — not a separate "module mode" you have to memorize. The rest of the post is detail about how the browser walks the graph it just built, starting with how a node names the neighbours it depends on.

How a module finds its dependencies: specifier resolution

A module asks for other modules with import:

import { render } from './render.js';
import { z } from 'https://esm.sh/zod@3';

The string after from is the module specifier, and the browser's rules for turning it into an actual file are stricter than most people expect — and stricter than Node's:

Specifier kindExampleResolves to
Relative'./render.js', '../lib/a.js'A URL relative to the importing module
Absolute path'/js/app.js'A URL relative to the site origin
Full URL'https://esm.sh/zod@3'Exactly that URL
Bare'zod', 'lodash/fp'Nothing — throws by default

Two rules trip people up:

  1. Extensions are required. The browser does not guess .js and does not look for an index.js in a folder. import './render' fails; you must write import './render.js'. (Node is the one that fills those in — not the browser.)
  2. Bare specifiers throw. import _ from 'lodash' gives you Failed to resolve module specifier "lodash". The browser has no node_modules and no idea where lodash lives.

That second rule is a real problem — every npm package is referenced by a bare name. The browser's answer is the import map: a small piece of JSON (JavaScript Object Notation) that tells the browser where bare names point.

<script type="importmap">
{
"imports": {
"lodash": "https://esm.sh/lodash@4",
"lodash/": "https://esm.sh/lodash@4/"
}
}
</script>

With that in the page, import _ from 'lodash' resolves to the mapped URL. A trailing-slash entry maps a whole subpath prefix (lodash/fp…/lodash@4/fp). An import map must appear before the first module that relies on it; modern browsers accept multiple maps and merge them, older ones allowed only a single map. Import maps are how you get bare-specifier ergonomics in the browser without a bundler.

note

Import maps also give you a single place to pin or swap versions, and to add integrity hashes for mapped modules — one edit re-points every importer.

Building the graph: how dependencies are tracked and kept in memory

This is the heart of it. Once the browser has an entry module, loading the graph happens in three distinct phases. Separating them is what lets ES Modules do things CommonJS can't — like handle cycles cleanly and share live values.

Phase 1 — Construction (fetch + parse)

Starting at the entry module, the browser fetches the file, parses it, and reads its import statements to find its dependencies. For each dependency it resolves the specifier (previous section) to a URL, and — if that URL isn't already in the module map — fetches and parses it too. Then it repeats, recursively, until the whole graph is discovered.

The module map is the in-memory registry, keyed by resolved URL, that makes this efficient and correct:

util.js is imported by both render.js and state.js, but it appears in the graph once. The first time the browser resolves its URL, it records "fetching util.js" in the map; the second importer finds it already there and links to the same entry instead of fetching again. The module map is why a dependency shared by fifty files is downloaded, parsed, and executed a single time — it is the browser's dependency cache for the life of the document.

Phase 2 — Instantiation (linking)

Now the browser walks the finished graph — depth-first, deepest dependencies first — and links it. For each module it creates the memory that will hold that module's exported values, and it points every import at the exact memory location of the matching export.

No user code runs in this phase. The browser is just wiring boxes together. Crucially, an import is connected to the export's storage location, not to a copied value — that's what makes bindings live (next section). This phase is also where a mismatched import is caught: import { nope } from './util.js' when util.js never exports nope fails here, before anything executes.

Phase 3 — Evaluation

Finally the browser runs each module's top-level code — again depth-first, dependencies before dependents — so that by the time a module runs, everything it imported has already been initialized. Each module body executes once; the module map flips it to "evaluated" and stores the result, so any later import (static or dynamic) gets the finished module namespace without re-running a line.

That three-phase split — discover, then link, then run — is the mental model to keep. Almost every "weird" module behavior below is a direct consequence of it.

Live bindings

Because linking connects an import to the export's storage, not a snapshot, an imported value is a live, read-only view of the exporter — when the exporting module changes it, every importer sees the new value.

// counter.js
export let count = 0;
export function increment() { count++; }

// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← the imported `count` updated itself

count in app.js isn't a copy that was 0 at import time — it's a window onto counter.js's variable. You cannot assign to it from the importer (imports are read-only), but you always read the current value. This is a genuine difference from CommonJS's require, which hands back a copy of whatever module.exports pointed at at that moment.

Circular dependencies

What if a.js imports b.js and b.js imports a.js? The three-phase model handles it without infinite loops, and understanding why tells you exactly when a cycle bites.

  • Construction doesn't loop forever: when b.js asks for a.js, the browser sees a.js is already in the module map (it's what started the chain) and links to it instead of re-fetching. The graph simply has an edge both ways.
  • Evaluation is where care is needed. Say a.js runs first. It runs top-down until it hits import … from './b.js', which pauses a to evaluate b. Now b runs, and it imports from a — but a is only partially evaluated. b gets a's live bindings, which is fine as long as b doesn't read a value that a hasn't assigned yet.
// a.js
import { b } from './b.js';
export const a = 'A';
console.log('in a, b =', b);

// b.js
import { a } from './a.js';
export const b = 'B';
console.log('in b, a =', a); // ReferenceError: a is not yet initialized

Function declarations are hoisted, so calling a function across a cycle works even mid-initialization. Reading a const/let value before its module has reached that line throws (the temporal dead zone, TDZ) — or, in looser cases, you see undefined. The rule of thumb: cycles are fine if the modules only call each other's functions at runtime, and fragile if they read each other's values during initialization. ES Modules degrade far more gracefully here than CommonJS, which would hand back a partially-filled exports object with no warning at all.

Top-level await

A module can await at its top level — no wrapping async function required:

// config.js
export const config = await fetch('/config.json').then(r => r.json());

This makes config.js an asynchronous module, and it changes evaluation: any module that imports an async module waits for it to finish before its own body runs. The browser turns that part of the evaluation phase into a promise chain — dependents await their dependencies' completion, while independent siblings can still evaluate in parallel. Your code stays synchronous-looking, but the graph's evaluation is now genuinely async under the hood. It's powerful for setup that needs data up front; used carelessly it can also stall a whole subgraph behind one slow await.

Loading on demand: dynamic import()

Everything so far uses static import at the top of a file, which the browser reads during construction. But you can also load a module at runtime:

button.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(data);
});

import() is a function-like form that returns a promise for the module namespace. It runs the same discover → link → evaluate pipeline, but on demand and rooted at the requested module. Two things make it powerful:

  • The specifier can be computed at runtime — e.g. import('./locales/' + lang + '.js') — impossible with static import, which must be statically analyzable.
  • It's the basis of code splitting. Deferring chart.js until the click means it isn't in the initial download at all.

And it still respects the module map: if chart.js (or any of its dependencies) was already loaded, import() resolves instantly from the cache rather than fetching or re-running anything.

Fetching the graph efficiently

The three-phase model has one performance cost worth understanding. Because the browser can't know what a.js imports until it has fetched and parsed a.js, a deep chain becomes a fetch waterfall — a sequence of round-trips, each one gated on the previous:

Each hop is a network round-trip the browser could only start after the previous file came back. A few tools flatten or shortcut this:

TechniqueWhat it doesWhen to reach for it
<link rel="modulepreload">Fetches (and parses/compiles) a module early, in parallel, warming the module map before its importer is even reachedYou know a deep dependency up front and want to skip the waterfall
<link rel="preload" as="script">Same idea for a classic script resourcePreloading a non-module script
fetchpriority="high"Bumps a script's fetch priorityA critical script competing with other downloads
BundlingMerges the graph into a few files at build time, so there's little waterfall left to fightProduction apps with deep or wide graphs

Native modules trade the bundler's build step for a runtime cost — the waterfall — which is exactly why bundling still earns its place for large browser apps even though the browser can load modules on its own. modulepreload is the browser-native mitigation: a hint that lets you fetch known-ahead dependencies in parallel instead of discovering them one hop at a time.

In practice: what your framework ships

You author in ES Modules no matter which framework you use — but whether the browser ever runs the native module-loading machinery in this post depends on your build tool, and for most production apps it doesn't. The bundler resolves the whole graph at build time and hands the browser something flatter and faster to load.

SetupProduction outputShips type="module"?Native module graph in the browser?
Next.js (webpack or Turbopack)Bundled chunks under /_next/static/chunks/No — classic <script defer>No — webpack's runtime resolves and loads chunks; the module map, specifier resolution, and import maps are all bypassed
Vite (production, Rollup)Bundled, but native module tagsYes — <script type="module"> + <link rel="modulepreload"> + crossoriginPartly — real module tags, but preloaded and pre-bundled so there's no waterfall left to discover
Vite (dev)Your unbundled source, served over the networkYesYes — the browser fetches each file as a native module

A few things worth pulling out of that table:

  • Next.js bundles and does not use native module tags. Even in its latest form — the App Router, with webpack or Turbopack — the scripts it emits are classic <script defer>, and webpack's own runtime does the chunk loading, so the browser never builds the module map described above. (Its polyfill bundle even ships as defer nomodule — the legacy no-modules fallback.)
  • Vite is the mainstream tool that genuinely ships type="module" to production, leaning on modulepreload to defeat the fetch waterfall. In development it goes further and serves your files unbundled as native modules, which is why its dev server starts instantly and can do Hot Module Replacement (HMR) — swapping a single changed module — without rebuilding a bundle.
  • Native modules, unbundled, still make sense for smaller sites, no-build ("buildless") setups, demos, internal tools, and ES Module libraries served straight from a content delivery network (CDN) — the cases where the waterfall is short enough not to hurt and skipping a build step is worth more than shaving those round-trips.

So the machinery in this post is real and worth understanding, but in a Next.js app you rarely touch it directly: the bundler is standing in for the browser's discover → fetch → link → evaluate loop, trading a build step for the runtime cost it would otherwise pay.

Loading scripts safely: SRI, CORS, and CSP

That's the whole load pipeline. Two things remain that apply to any script the browser loads, module or not: keeping third-party code trustworthy, and the full set of attributes the tag accepts. Take safety first.

When a script comes from somewhere you don't fully control — a CDN, a third party — three mechanisms keep it honest. They're separate tools that combine well:

  • integrity — Subresource Integrity (SRI). A hash of the expected file (sha256/sha384/sha512, base64). The browser hashes what it fetched and refuses to run it if the hash doesn't match, so a tampered or swapped CDN file simply won't execute.
  • crossorigin — Cross-Origin Resource Sharing. SRI on a cross-origin file requires this: a script fetched in the default no-cors mode is opaque, and the browser won't hash what it can't read, so it rejects the request. anonymous sends no credentials; use-credentials sends cookies. It also unlocks full error messages for cross-origin scripts.
  • nonce — Content Security Policy (CSP). A CSP can forbid all inline/injected scripts except those carrying a matching per-response nonce token, blocking attacker-injected <script> tags.
<script
src="https://cdn.example.com/chart.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQhoQX3nnw6"
crossorigin="anonymous"
nonce="r4nd0m-per-request"
defer></script>
Content-Security-Policy: script-src 'self' 'nonce-r4nd0m-per-request'

The division of labor: CSP decides which scripts may run, while SRI verifies a script wasn't tampered with once it's allowed. Strict setups use both.

The <script> attribute reference

Everything the tag can carry, ordered by how often you'll reach for it:

AttributeWhat it doesHow often you'll reach for it
srcPoints at an external script by its URL. Omit it and the script is inline.Essential
typeSelects the script's mode: classic (default), module, importmap, speculationrules, or a non-executed data block.Very common
deferExternal classic scripts: fetch in parallel, execute in order after parsing.Common
asyncExternal scripts: fetch in parallel, execute as soon as ready, order not guaranteed.Common
integritySubresource Integrity hash; mismatch blocks execution.Situational
crossoriginCORS mode (anonymous / use-credentials). Required for SRI and modules cross-origin.Situational
noncePer-response token matching a CSP nonce-… so the script may run.Situational
fetchpriorityHints the fetch priority (high / low / auto).Niche
referrerpolicyControls the Referer header sent when fetching the script.Niche
nomoduleScript runs only in (old) browsers without module support — the legacy fallback.Legacy
blockingblocking="render" forces the script to be render-blocking until fetched and run.Niche
attributionsrcRegisters an Attribution Reporting source/trigger. Experimental, Chromium-only.Experimental
charset / languageLegacy encoding/type hints.Deprecated

Reach for it constantly Often Sometimes Rarely / avoid

And the ones to leave alone:

AttributeWhy it's gone
charsetDocuments are UTF-8 (Unicode Transformation Format); a mismatched charset on a script is invalid anyway.
languageSuperseded by type decades ago.
event / forNever standardized — old Internet Explorer only.

The whole thing in one picture

Put together, "the browser loads a script" means:

  1. The parser hits a <script> and decides when to run it (blocking, async, defer, or a deferred module).
  2. For a module, it reads the imports and resolves each specifier to a URL (import maps supply bare names).
  3. It fetches the graph, recording every file in the module map so each is loaded once.
  4. It links the graph — wiring imports to export storage, which makes bindings live.
  5. It evaluates depth-first, running each module body once, and caches the result.
  6. Cycles, top-level await, and dynamic import() are all just variations on how that graph is walked.

The single sentence to keep: the browser builds a dependency graph in memory, fetches and runs each file exactly once, and everything else — order, live bindings, cycles, lazy loading — falls out of how it walks that graph.

References