JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
The document object and how pages load
You can tell what part of the DOM exists at any moment during page load, and choose between defer, DOMContentLoaded, and load for your init code.
What you will learn
- Read document.readyState to tell whether the DOM is still being parsed
- Choose DOMContentLoaded for tree work and window load for image and iframe sizes
- Use defer so a head script sees the finished tree without blocking the parser
- Guard late-running code with a readyState check instead of trusting DOMContentLoaded
Understanding The document object and how pages load
When a page arrives the browser does not hand your code the HTML text; it feeds those bytes to a parser that creates one object per tag and links them into a tree. The document object owns that tree, which is why every landmark hangs off it: document.documentElement is the html element, document.head and document.body are the two children you actually work with, and document.title, document.URL and live collections such as document.forms and document.images sit alongside them. Because document describes the tree in memory rather than the file on disk, what you read from it includes every change a script has already made, which is why it can disagree with 'view source'.
The tree is not finished all at once. The parser works top to bottom and appends each node as it recognises it, and when it meets a classic script element it stops parsing, runs that script to completion, and only then continues. That single rule explains most load-order surprises: a script can reach only the markup written above it, because the markup below it does not exist as nodes yet. document.readyState names the phase you are in, moving from 'loading' while nodes are still being added, to 'interactive' once the whole tree exists, to 'complete' once images, stylesheets and iframes have finished as well.
Two events mark those transitions. DOMContentLoaded fires on document when parsing and any deferred scripts are done, and it deliberately does not wait for images, so it is the right moment to wire up an interface. The window load event does wait for subresources, so it is the only place where an image's real dimensions are already known. Adding defer to an external script moves it to just before DOMContentLoaded while keeping document order, async gives up that ordering entirely, and because both events fire exactly once and are never replayed, code that might arrive late has to test document.readyState instead of blindly adding a listener.
// index.html has <head><script src='app.js'></script></head> -- no defer, no async.
// app.js:
console.log('script runs, readyState:', document.readyState);
console.log('document.body:', document.body);
console.log('root element:', document.documentElement.tagName);
document.addEventListener('DOMContentLoaded', function () {
console.log('DOMContentLoaded, readyState:', document.readyState);
console.log('body exists:', document.body.tagName);
});
window.addEventListener('load', function () {
console.log('load, readyState:', document.readyState);
});document is a live tree the parser builds top to bottom, so what your code can reach depends on when it runs, not on what the HTML file contains.
Worked examples
A script watching the parser mid-page
An inline script placed between two paragraphs shows that only the markup above it has become part of the tree.
<body>
<p id='a'>above</p>
<script>
console.log('above me:', document.getElementById('a').tagName);
console.log('below me:', document.getElementById('b'));
console.log('elements in body so far:', document.body.children.length);
document.addEventListener('DOMContentLoaded', function () {
console.log('elements in body at the end:', document.body.children.length);
});
</script>
<p id='b'>below</p>
</body>Example explained
Line 1The lookup for 'a' succeeds because that paragraph was appended to the tree before the parser reached the script.
Line 2The lookup for 'b' returns null: the bytes are in the file, but no node exists for them yet.
Line 3children.length is 2 because the running script element is itself a node in the body.
Line 4At DOMContentLoaded the parser has finished, so the third element, the paragraph after the script, is counted too.
Timing seen from a deferred script
A defer script starts after parsing has already ended, so it misses the switch to 'interactive' entirely.
// loaded with <script src='app.js' defer></script> in <head>
console.log('defer script:', document.readyState);
document.addEventListener('readystatechange', function () {
console.log('readystatechange ->', document.readyState);
});
document.addEventListener('DOMContentLoaded', function () {
console.log('DOMContentLoaded');
});
window.addEventListener('load', function () {
console.log('load ->', document.readyState);
});Example explained
Line 1The first line already reports 'interactive' because deferred scripts run after the parser is done with the markup.
Line 2The readystatechange listener never reports 'interactive': that change happened before the listener existed, and events are not replayed.
Line 3DOMContentLoaded comes after deferred scripts have all executed, which is why a defer script needs no DOMContentLoaded wrapper to see the tree.
Line 4readyState is set to 'complete' in the same task that fires load, so the readystatechange line is printed just before the load line.
Initialising when the event may already be gone
A readyState check lets the same init function work whether it is called during parsing or long after loading finished.
// plain <script> in <head>, no defer
function whenReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
}
whenReady(function () {
console.log('init at readyState:', document.readyState);
});
window.addEventListener('load', function () {
// stands in for code that arrives late: injected or async scripts
document.addEventListener('DOMContentLoaded', function () {
console.log('this never runs');
});
whenReady(function () {
console.log('late init at readyState:', document.readyState);
});
});Example explained
Line 1The first call sees 'loading', so it queues the function and the function runs once the tree is complete.
Line 2The listener registered inside the load handler is silently dead: DOMContentLoaded fired earlier and is not sticky.
Line 3The second whenReady call sees 'complete' and calls the function immediately, which is the only way late code can still initialise.
Line 4{ once: true } drops the handler after it fires, so repeated whenReady calls do not accumulate listeners.
Important notes
defer only affects external scripts; it is ignored on an inline script, and type='module' is deferred already, so adding defer there changes nothing.
A stylesheet still being downloaded can push DOMContentLoaded later, because a classic script after the link tag must wait for the CSS and the parser waits for that script.
Common mistakes
Leaving an external script in the head without defer and then touching document.body or an element: body is still null at that moment, so the script dies with 'Cannot read properties of null'.
Putting all setup inside window load: the code waits for the last image, font file or third-party iframe, so buttons stay unresponsive for seconds after the page looks finished.
Attaching a DOMContentLoaded listener from a script that may run late, such as an async or dynamically inserted one: the callback never fires and no error is reported.
Try it yourself
Change, predict, then run
Build a page with one large image (append ?v=1 and disable the cache so it is really fetched) and a deferred script that logs document.readyState together with document.images[0].complete inside a DOMContentLoaded handler and inside a window load handler, then explain why the two complete values differ.
Open the JavaScript workspaceCheck your understanding
A third-party widget is added as <script src='widget.js' async>, and widget.js sets up its interface inside a DOMContentLoaded listener. On some visits the widget appears, on others it never initialises. What explains this?
- DOMContentLoaded only fires for scripts that were present in the original HTML source.
- The listener should be attached to window, because DOMContentLoaded is a window event and not a document event.
- An async script can finish downloading after DOMContentLoaded has already fired, and a missed event is never replayed for a listener added later.
- async scripts run in a separate context that has no access to document-level events.
Show answer
An async script executes as soon as its download completes, which may land before or after the parser finishes, so on slow loads the listener is registered after DOMContentLoaded has already fired; DOM events happen once and are not queued for future listeners, so the callback simply never runs. Switching to window would not help, because DOMContentLoaded is dispatched at document and bubbles up to window, so both targets behave identically; the fix is to check document.readyState and initialise immediately when it is no longer 'loading'.