Appearance
6.3.2 — Loading Scripts & Watching the Page
Chapter 6.1.2 established that a classic <script> tag stops the HTML parser dead. This page is about the attributes that change that, and then about a second problem the event model cannot solve on its own: knowing when something has become true — an element has scrolled into view, a subtree has changed, a box has been resized — without asking over and over.
Both halves come back to the same constraint from Chapter 6.1.1. The main thread does one thing at a time, so both the loading strategy and the watching strategy are really about not occupying it.
1. The four ways a script can load
html
<script src="a.js"></script> <!-- (1) classic: blocks parsing -->
<script src="b.js" async></script> <!-- (2) runs the moment it arrives -->
<script src="c.js" defer></script> <!-- (3) runs after parsing, in order -->
<script src="d.js" type="module"></script> <!-- (4) deferred by default -->Classic (1). Parsing stops, the file downloads, it executes, parsing resumes. Everything below the tag does not exist in the DOM yet — this is why document.querySelector('#app') from a <head> script returns null.
async (2). Downloads in parallel with parsing, and executes the instant it arrives, interrupting parsing at whatever point that happens to be. Two async scripts run in download order, which is effectively random, so async is only correct for a script that depends on nothing and that nothing depends on. Analytics is the usual example.
defer (3). Downloads in parallel and executes after parsing is complete, in document order, just before DOMContentLoaded. The whole DOM is available, order is guaranteed, and nothing was blocked. This is the right default for application code.
type="module" (4) behaves like defer automatically — no attribute needed. Adding async to a module makes it behave like async.
Four consequences of type="module" that are easy to trip over:
- It runs in strict mode always, and has its own top-level scope, so a top-level
constis not a global. - A module is evaluated once no matter how many times it is imported — the module registry deduplicates by resolved URL (Chapter 3.6.5).
- It is fetched with CORS, so a cross-origin module needs the right headers (Chapter 6.10), and — the one that catches everyone locally —
file://does not work. You need an HTTP server even for a static page. - It supports top-level
await, which delays the module's evaluation and everything importing it.
defer is ignored on an inline script. <script defer>const x = 1</script> runs immediately, because there is nothing to download and the attribute is defined in terms of the fetch. If you need an inline script to run late, use type="module", which does defer inline code.
The two events that mark the end
DOMContentLoaded fires when the HTML is fully parsed and all defer scripts and modules have run. Stylesheets and images may still be loading. This is where application code starts.
load fires when everything has arrived — images, stylesheets, iframes, fonts. It is much later and usually the wrong hook. Waiting for load before showing an interface means a single slow image at the bottom of the page delays the whole thing.
js
// Runs at DOMContentLoaded, or immediately if the DOM is already parsed.
// (The second half matters when the script loads dynamically, after parsing.)
const start = () => initialiseApp();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}That guard is worth writing out because the naive version — just adding the listener — silently never runs when the script is injected later, which is exactly what happens in a lazily loaded widget.
Dynamic import
js
// (1) Returns a promise for the module namespace. The file is fetched on demand.
button.addEventListener('click', async () => {
const { openEditor } = await import('./rich-text-editor.js');
openEditor();
});This is the mechanism behind code splitting: the editor's code is a separate file that is never downloaded by a user who does not open the editor. Bundlers see import() and emit a separate chunk automatically (Chapter 6.6). Line (1) is also the answer to "how do I load a module conditionally", which static import cannot do.
Telling the browser what is coming
The preload scanner from Chapter 6.1.2 finds URLs in the HTML. These hints extend that to things it cannot find, and each does a different amount of work:
html
<link rel="preconnect" href="https://cdn.example.com"> <!-- (1) -->
<link rel="dns-prefetch" href="https://cdn.example.com"> <!-- (2) -->
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin> <!-- (3) -->
<link rel="modulepreload" href="/js/router.js"> <!-- (4) -->
<link rel="prefetch" href="/checkout.js"> <!-- (5) -->(1) does DNS, TCP and TLS in advance — three round trips saved for a resource you know you will fetch. (2) does DNS only; it is the cheaper, older hint. (3) downloads a resource for this page at high priority; as is mandatory because it sets the priority and the request headers, and crossorigin is mandatory for fonts even from your own origin, because fonts are always fetched in CORS mode. (4) is the module equivalent, which also resolves and preloads the module's own dependencies. (5) downloads at lowest priority for a future navigation — the right hint for the next page, and the wrong one for anything on this page.
Use these sparingly. Every preload competes for bandwidth with everything else, and preloading five things means the browser's carefully computed priority order has been overridden five times by a human guess. A preload for something that ends up unused is pure waste, and the browser will tell you so in the console.
2. Why observers exist
Suppose you want to load images as they scroll into view. The obvious implementation:
js
window.addEventListener('scroll', () => {
for (const img of images) {
const rect = img.getBoundingClientRect();
if (rect.top < window.innerHeight) load(img);
}
});This is wrong in three separate ways at once. scroll fires at a very high rate, on the main thread. getBoundingClientRect() forces a synchronous layout (Chapter 6.1.2), so you force one per image per scroll event. And the work happens during scrolling, which is the exact moment the user will notice a dropped frame.
The observer APIs invert this. Instead of you asking repeatedly, you register interest and the browser tells you when something changed — computing the answer off the main thread where it can, and delivering results in batches.
3. IntersectionObserver — is it in view?
ts
// (1) The callback receives every entry whose visibility CHANGED.
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue; // (2)
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!; // (3)
observer.unobserve(img); // (4) done — stop watching
}
}, {
root: null, // (5) the viewport
rootMargin: '200px 0px', // (6) start 200px early
threshold: 0, // (7) any pixel counts
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));Line (1) is called with only the elements whose intersection state changed, not all of them — the browser does the filtering.
Line (2): entries arrive for both entering and leaving, so you must check isIntersecting rather than assuming a callback means "visible".
Line (3) swaps a placeholder for the real source. Line (4) is the part people forget: an observer holds a reference to every element it watches, so once the job is done, stop watching. Ten thousand observed elements that never get unobserved is a memory leak with no obvious symptom.
Line (5) root: null means the viewport. Set it to a scrollable element to observe within that element instead.
Line (6) rootMargin grows the detection box, so the image starts loading 200 pixels before it appears. This is what makes lazy loading invisible to the user — with a margin of zero, the image starts loading exactly when it comes into view and the user watches it appear.
Line (7) threshold is how much of the element must be visible: 0 means one pixel, 1 means all of it, 0.5 means half. An array like [0, 0.25, 0.5, 0.75, 1] fires at each crossing, which is how a scroll-progress indicator is built.
Infinite scroll, built properly
The naive version attaches a scroll handler and compares offsets. The correct version watches a single empty element at the bottom of the list:
ts
const sentinel = document.querySelector('#sentinel')!;
let loading = false; // (1)
let nextCursor: string | null = 'start';
const io = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting) return;
if (loading || nextCursor === null) return; // (2)
loading = true;
try {
const page = await fetchProducts(nextCursor); // (3)
list.append(renderRows(page.items));
nextCursor = page.nextCursor; // (4)
if (nextCursor === null) io.unobserve(sentinel); // (5)
} catch {
// (6) leave `loading` false so the next scroll retries
} finally {
loading = false;
}
}, { rootMargin: '400px' });
io.observe(sentinel);Line (1) and line (2) are the guard that separates a working infinite scroll from a broken one. Without the loading flag, a fast scroll fires the callback again before the first request returns and you fetch page 2 three times, appending duplicates. This is the single most common bug in infinite scroll implementations, and it appears only on fast connections or fast scrolling, which is why it survives testing.
Line (3) uses the cursor pagination from Chapter 9.6.2 rather than an offset, and the reason is the same one that chapter gives: with offsets, an item inserted at the top while the user is reading shifts everything down, so page 2 repeats a row that was on page 1.
Line (4) and (5) handle the end of the data: when the server says there is no next cursor, stop observing so the sentinel stops firing.
Line (6) is deliberate — on failure, loading returns to false and the next intersection retries. That is the correct behaviour for a transient network error, though a production version would also show the user that something failed rather than silently retrying forever.
Two things to add for a real product. An infinite list must also be virtualised once it is long, or the DOM grows without bound and layout slows down — Chapter 6.9 covers that. And infinite scroll makes the footer unreachable and breaks the back button unless you restore position, which is why "load more" buttons are often the better product decision.
The other things IntersectionObserver is for
Impression tracking — "was this advert on screen for at least one second, at least 50% visible" is threshold: 0.5 plus a timer, and it is the standard definition.
Sticky-state detection — a sticky header cannot tell you when it became stuck. Observe a one-pixel sentinel just above it; when the sentinel leaves the viewport, the header is stuck, and you can add a shadow.
Pausing off-screen work — stop a video, an animation, or a polling timer when its container is not visible.
4. MutationObserver — did the DOM change?
ts
const mo = new MutationObserver((records) => { // (1) records, plural — batched
for (const record of records) {
if (record.type === 'childList') {
record.addedNodes.forEach(enhanceIfNeeded);
}
}
});
mo.observe(container, {
childList: true, // (2) children added or removed
subtree: true, // (3) …anywhere beneath, not just direct children
attributes: true, // (4) attribute changes
attributeFilter: ['data-state'], // (5) only this one — much cheaper
characterData: false,
});Line (1) is the important design decision: the callback receives an array of records and is delivered as a microtask after the current task completes. Twenty DOM changes in one function produce one callback with twenty records, not twenty callbacks. Chapter 3.6.8's microtask rules apply exactly.
Lines (2)–(5) are the options, and at least one of childList, attributes or characterData must be true or observe throws. Line (3) subtree is what makes it watch the whole tree; without it, only direct children count. Line (5) is a real performance lever — watching all attributes on a busy subtree produces a lot of records you will discard.
When you actually need this. Your own code should not need to observe its own DOM changes — it knows it made them. MutationObserver earns its place when something else controls the DOM: a third-party script or embedded widget you must react to, a rich-text editor's contenteditable area, or an accessibility or analytics layer that must notice content it did not render. If you find yourself observing markup your own framework renders, the state that drove that render is the thing to watch instead.
Always disconnect() when finished. And be careful not to write a callback that mutates the same subtree it observes — that is an infinite loop, and the batching means it can run for a while before you notice.
5. ResizeObserver — did this element change size?
window.resize tells you the window changed. It says nothing about an element that changed because a sibling grew, a font loaded, content was added, or a container query fired.
ts
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
// (1) The layout box, without forcing a layout to read it.
const width = entry.contentBoxSize[0].inlineSize;
entry.target.classList.toggle('is-narrow', width < 480);
}
});
ro.observe(chartContainer);Line (1) is the quiet benefit: the entry already carries the measurement, so you get the size without calling getBoundingClientRect() and therefore without forcing layout. The observer runs after layout has been computed, so the number is free.
The box options are contentBoxSize (inside the padding), borderBoxSize (including padding and border), and devicePixelContentBoxSize (in real device pixels, which is what a <canvas> needs to render crisply on a high-density screen — Chapter 6.9).
The error you will eventually see: ResizeObserver loop completed with undelivered notifications. It means your callback changed a size, which triggered the observer again, which changed a size again, and the browser stopped the loop to protect the frame. The fix is to make the callback's changes not affect the observed dimension — toggle a class that changes colour rather than width, observe a parent and resize a child, or guard against re-entry with a stored last value.
Where it earns its place: charts that must re-render at a new size, a text area that grows with content, canvas sizing, and any component that needs to adapt to its own width in JavaScript. For adapting styling to a component's width, container queries (Chapter 6.2.5) are now the better tool and need no JavaScript at all.
What the interviewer will push on
"async versus defer?" Both download in parallel with parsing. async executes the moment it lands, interrupting parsing, in unpredictable order. defer executes after parsing, in document order, just before DOMContentLoaded. defer for application code, async only for a script that depends on nothing. Then add that type="module" is deferred by default.
"What is wrong with a scroll handler that calls getBoundingClientRect?" It runs at very high frequency on the main thread and forces a synchronous layout every call, during the exact interaction where a dropped frame is most visible. IntersectionObserver moves the work off your hands and batches the results.
"Build infinite scroll." A sentinel element plus an IntersectionObserver with a rootMargin. Then volunteer the two things that separate a working one from a broken one: an in-flight guard so a fast scroll does not fetch the same page three times, and cursor pagination so an insert at the top does not duplicate a row. Mentioning virtualisation and the unreachable footer moves it from a coding answer to an engineering one.
"When would you use MutationObserver?" When something other than your own code changes the DOM — third-party widgets, contenteditable, an analytics layer. If you are observing your own framework's output, watch the state instead. Note that callbacks are batched into one microtask.
"Why ResizeObserver rather than window.resize?" Elements change size for reasons unrelated to the window: siblings, fonts, content. And the entry carries the measurement, so you avoid forcing layout. If they ask about ResizeObserver loop errors, explain the self-triggering feedback loop.
"When is rel=preload a bad idea?" When you are guessing. Every preload overrides the browser's priority ordering, and a preload for something unused is wasted bandwidth on the critical path. prefetch is the low-priority, next-navigation version and must not be confused with it.
One thing to volunteer: point out that defer is ignored on inline scripts but type="module" defers them, and that a module cannot be loaded from file://. Both are small, both cost people an hour the first time, and knowing them signals you have actually shipped a page rather than only configured a bundler.
Recall
- Classic
<script>blocks parsing ·asyncruns the instant it lands, in download order ·deferruns after parsing, in document order, just beforeDOMContentLoaded·type="module"is deferred automatically. - Modules are strict, scoped, evaluated once per URL, fetched with CORS (so no
file://), and support top-levelawait.deferis ignored on inline scripts;type="module"is not. DOMContentLoaded= HTML parsed and deferred scripts run.load= every image and stylesheet too, usually far too late to gate an interface on.import()returns a promise and is the mechanism behind code splitting and conditional loading.preconnect(DNS+TCP+TLS) ·dns-prefetch(DNS only) ·preload(this page, high priority,asmandatory,crossoriginfor fonts) ·modulepreload·prefetch(next navigation, lowest priority). Overuse overrides the browser's own priorities.- Observers invert the polling model: register interest, get batched callbacks, avoid forcing layout. A
scrollhandler callinggetBoundingClientRect()is the anti-pattern they replace. IntersectionObserver:root,rootMargin(start early so lazy loading is invisible),threshold. CheckisIntersecting, andunobservewhen done or you hold references forever.- Infinite scroll = sentinel + observer + an in-flight guard (or a fast scroll fetches the same page repeatedly) + cursor pagination (or an insert at the top duplicates rows).
MutationObserverbatches records into one microtask; needs at least one ofchildList/attributes/characterData; use it for DOM you do not control, not your own renders.ResizeObserverwatches elements, and its entry carries the size without forcing layout. Theloop completed with undelivered notificationserror means the callback changed the dimension it observes.
Self-test: Why does a <head> script find document.body null, and which attribute fixes it? · Why is async wrong for application code? · What makes lazy loading feel instant rather than visible? · What two bugs does an infinite scroll have without a guard and without cursors? · Why is ResizeObserver cheaper than measuring in a resize handler?
Next: 6.3.3 covers what the page does with data — sending a form the two different ways, the four places the browser can store things and which one is right, and how to move expensive work off the main thread entirely.