DOMContentLoaded vs defer: Why DOM Code Runs Too Early
DOMContentLoaded vs defer is not a style preference. It is about when your JavaScript runs compared with when the browser has created the DOM nodes your code wants to use. If a script runs too early, querySelector returns null, and the next line usually turns into a TypeError.
The fix is simple once you stop treating the DOM as if it appears all at once.
Contents
- The crash
- Why the code runs too early
- What defer changes
- What DOMContentLoaded changes
- Which one should you use
- How this differs from load
- A safe setup pattern
- Key Takeaways
The crash
This HTML looks normal:
<!doctype html>
<html>
<head>
<script src="/app.js"></script>
</head>
<body>
<button id="save-button">Save</button>
</body>
</html>
And this JavaScript looks normal:
const button = document.querySelector("#save-button");
button.addEventListener("click", () => {
console.log("Saved");
});
But it can fail with:
TypeError: Cannot read properties of null (reading 'addEventListener')
The selector is correct. The button exists in the file. The problem is timing.
When the browser reaches the script in the head, it runs app.js immediately. At that moment, the browser has not parsed the body yet. The button is still just text waiting later in the HTML stream, so the DOM query returns null.
For the broader selector checklist, see why querySelector returns null.
Why the code runs too early
Browsers parse HTML from top to bottom. A normal script tag blocks parsing while the script downloads and runs.
<head>
<script src="/app.js"></script>
</head>
<body>
<button id="save-button">Save</button>
</body>
The timeline is:
Start parsing HTML
Enter head
Find script tag
Download and run app.js
app.js asks for #save-button
#save-button does not exist yet
Continue parsing body
Create button
The code is not "too fast" in a performance sense. It is early in the document lifecycle.
Moving the script to the end of body works because the button is parsed first:
<body>
<button id="save-button">Save</button>
<script src="/app.js"></script>
</body>
That pattern is valid, but modern pages usually use defer for external scripts.
What defer changes
defer tells the browser two things:
- Download this external script without stopping HTML parsing.
- Run it after the document has been parsed.
<head>
<script src="/app.js" defer></script>
</head>
<body>
<button id="save-button">Save</button>
</body>
Now the timeline is:
Start parsing HTML
Find deferred script and begin downloading it
Keep parsing the document
Create #save-button
Finish parsing the document
Run app.js
Fire DOMContentLoaded
That means this code can live at the top level of app.js:
const button = document.querySelector("#save-button");
button?.addEventListener("click", () => {
console.log("Saved");
});
Use defer for normal external scripts that set up page behavior. It keeps the JavaScript file clean and avoids wrapping the whole file in a readiness callback.
Two details matter:
deferworks on external scripts with asrcattribute.- Deferred scripts keep their document order, so multiple deferred files run in the order they appear in the HTML.
That second point is useful when one script defines helpers and another script uses them. Still, if your scripts depend on each other too heavily, a bundler or module structure is usually cleaner than arranging tags like a tiny ritual.
What DOMContentLoaded changes
DOMContentLoaded is an event fired on document after the HTML has been parsed and deferred scripts have run. If your code runs before the DOM is ready, you can wait for that event.
document.addEventListener("DOMContentLoaded", () => {
const button = document.querySelector("#save-button");
button?.addEventListener("click", () => {
console.log("Saved");
});
});
This is useful when:
- You do not control the script tag.
- The script is inline in the
head. - You are writing a snippet that may be pasted into different pages.
- You need defensive setup in an environment with unpredictable script placement.
The callback runs once the document is ready for DOM queries. Images may still be loading. Fonts may still be loading. That is fine for most event setup and DOM selection work.
Which one should you use
Use defer by default for your own external scripts.
<script src="/app.js" defer></script>
Then write setup code normally:
const form = document.querySelector("#signup-form");
form?.addEventListener("submit", (event) => {
event.preventDefault();
console.log("Submit the form");
});
Use DOMContentLoaded when you cannot rely on defer:
<script>
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#signup-form");
form?.addEventListener("submit", (event) => {
event.preventDefault();
console.log("Submit the form");
});
});
</script>
The practical decision table is:
| Situation | Good default |
|---|---|
| External script you control | defer |
Inline script in the head | DOMContentLoaded |
| Third-party snippet placement | DOMContentLoaded |
Script at end of body | No wrapper needed |
| ES module script | Usually no wrapper needed for initial DOM selection |
Module scripts are deferred by default, so <script type="module" src="/app.js"></script> already waits until parsing is done before executing. That is one reason modern examples often work without an explicit defer.
How this differs from load
DOMContentLoaded is not the same as the load event.
| Event | Fires when |
|---|---|
DOMContentLoaded | The HTML document has been parsed and the DOM is ready |
load | The page and dependent resources like images have finished loading |
For DOM setup, load is usually too late.
window.addEventListener("load", () => {
console.log("Everything finished loading");
});
Use load when you need image dimensions, final resource loading, or behavior that truly depends on page assets. Use DOMContentLoaded or defer when you just need elements to exist.
Waiting for every image before attaching a click handler is not careful. It is just making the user wait for no benefit, which is a very web-shaped way to lose goodwill.
A safe setup pattern
Optional chaining can prevent crashes:
const button = document.querySelector("#save-button");
button?.addEventListener("click", () => {
console.log("Saved");
});
But optional chaining should not become a blindfold. If the page requires the button, make the missing element obvious during development.
function requireElement<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) {
throw new Error(`Missing required element: ${selector}`);
}
return element;
}
const button = requireElement<HTMLButtonElement>("#save-button");
button.addEventListener("click", () => {
console.log("Saved");
});
With a deferred script, that error now means the selector or markup is wrong. Without defer, the same error might only mean the code ran too early. Good timing rules make the remaining bugs easier to trust.
If the element will be added later by JavaScript, neither defer nor DOMContentLoaded magically finds it before it exists. Query after rendering, or use event delegation for interactions on dynamic children.
For guided practice, try the Introduction to Events lesson.
Key Takeaways
- DOM code runs too early when a script executes before the elements below it have been parsed.
- Use
deferby default for external scripts you control. - Use
DOMContentLoadedwhen script placement is unpredictable or inline code must wait for the DOM. DOMContentLoadedis earlier thanload; do not wait for images just to attach event handlers.- Once timing is fixed, missing elements usually mean a selector, markup, or rendering-order bug.