Knowledge Base/guides/DOMContentLoaded vs defer: Why DOM Code Runs Too Early

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

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:

  1. Download this external script without stopping HTML parsing.
  2. 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:

  • defer works on external scripts with a src attribute.
  • 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:

SituationGood default
External script you controldefer
Inline script in the headDOMContentLoaded
Third-party snippet placementDOMContentLoaded
Script at end of bodyNo wrapper needed
ES module scriptUsually 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.

EventFires when
DOMContentLoadedThe HTML document has been parsed and the DOM is ready
loadThe 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 defer by default for external scripts you control.
  • Use DOMContentLoaded when script placement is unpredictable or inline code must wait for the DOM.
  • DOMContentLoaded is earlier than load; 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.
Share this article:
javascriptdomdebuggingbrowser-apis