Loading Scripts: defer, async and type=module

Where a script tag sits and which attribute it carries decides whether your page paints in half a second or four. Three loading behaviours, and when each is correct.

Concept

A script element can stop the browser dead. Understanding exactly when it does is the difference between a page that appears instantly and one that shows a blank screen while a file downloads.

The three behaviours

Blocking - no attribute

<script src="/scripts/app.js"></script>

The parser stops. It downloads the file, runs it, and only then continues building the DOM. Everything below that line is unparsed and therefore unpainted for the whole duration.

In the head this is the single most damaging thing you can do to perceived performance.

defer

<script src="/scripts/app.js" defer></script>

Downloads in parallel with parsing, and runs after the document is fully parsed but before DOMContentLoaded. Multiple deferred scripts run in the order they appear in the markup.

This is the right default for almost everything.

async

<script src="/scripts/analytics.js" async></script>

Downloads in parallel and runs the moment it arrives, interrupting the parser at whatever point that happens to be. Order is not guaranteed - the fastest download runs first.

Only correct for independent scripts that do not touch the DOM and do not depend on anything else. Analytics is the standard example.

Comparison

Blocks parsingRuns whenOrder kept
No attributeYesImmediatelyYes
deferNoAfter parsingYes
asyncOnly while executingOn arrivalNo
type="module"NoAfter parsingYes

type="module"

<script type="module" src="/scripts/main.js"></script>
// main.js
import { renderCourses } from "./courses.js";
renderCourses(data);

Modules bring several behaviours at once:

  • Deferred by default. No attribute needed.
  • Strict mode always.
  • Their own scope. Top level variables do not leak onto window.
  • Executed once, even if imported many times.
  • Fetched with CORS rules, so a cross origin module needs the right headers.
  • Do not work over file://, which is a common first surprise. Serve over localhost.
<!-- modern browsers run this and ignore the next line -->
<script type="module" src="/scripts/main.js"></script>

<!-- module aware browsers ignore nomodule; older ones run it -->
<script nomodule src="/scripts/legacy.js" defer></script>

Where to put the tag

<head>
  <script src="/scripts/app.js" defer></script>
</head>

With defer, the head is the best place: the download starts as early as possible and execution still waits for a complete DOM. The old advice to put scripts at the end of the body was a workaround for not having defer, and it delays the download.

Inline scripts

<script>
  document.documentElement.classList.add("js");
</script>

An inline script always blocks and defer and async have no effect on it. Keep any inline script tiny - a class on the root element, a theme applied before paint - and put everything else in a file.

The one legitimate case for a blocking inline script is preventing a flash: reading a stored theme and applying it before the first paint.

<script>
  try {
    const theme = localStorage.getItem("theme");
    if (theme) document.documentElement.dataset.theme = theme;
  } catch (e) {}
</script>

Loading order in practice

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Design course</title>

  <link rel="stylesheet" href="/styles/site.css">

  <script src="/scripts/app.js" defer></script>
  <script src="/scripts/analytics.js" async></script>
</head>

The stylesheet blocks painting - which is correct. The application script downloads alongside parsing and runs when the DOM is ready. Analytics runs whenever it arrives and affects nothing.

Waiting for the DOM

// unnecessary in a deferred script - the DOM is already complete
document.addEventListener("DOMContentLoaded", () => { ... });

// needed only in a blocking or async script that touches the DOM
if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init);
} else {
  init();
}
EventFires when
DOMContentLoadedThe DOM is built. Images may still be loading.
loadEverything has finished, including images and iframes.

Most code wants DOMContentLoaded, or nothing at all when using defer.

Third party scripts

<script src="https://cdn.example.com/widget.js"
        async
        crossorigin="anonymous"
        integrity="sha384-..."></script>

integrity is a subresource integrity hash. The browser verifies the downloaded file against it and refuses to run anything that does not match, which protects against a compromised CDN serving altered code. It requires crossorigin to be set.

Every third party script is a security and performance decision. It runs with full access to your page: it can read the DOM, read cookies available to script, and modify anything. Add each one deliberately.

Important rules

  • defer and async apply only to external scripts, never to inline ones.
  • defer preserves order; async does not.
  • Modules are deferred automatically.
  • Modules do not work from file://.
  • A blocking script in the head delays the first paint by its entire download and execution time.
  • A script cannot reach an element that has not been parsed yet.

Common mistakes

  • A blocking script in the head, then blaming the server for slow rendering.
  • Adding defer to an inline script, where it does nothing.
  • Using async for scripts that depend on each other, producing random failures.
  • Opening a module page from the file system and seeing a CORS error.
  • Wrapping deferred code in DOMContentLoaded unnecessarily.
  • Loading a large library for one small feature.
  • Adding third party scripts with no integrity hash and no review.

Best practices

  • defer on every script unless there is a specific reason otherwise.
  • async only for genuinely independent scripts.
  • Prefer type="module" for new work.
  • Keep inline scripts to a few lines.
  • Put script tags in the head with defer, not at the end of the body.
  • Add integrity and crossorigin to third party scripts.
  • Measure with the Network panel rather than guessing.

Practice

  1. Put a script in the head that loops for two seconds before the body, and describe what the reader sees. Add defer and compare.
  2. Load three async scripts that log their names, reload several times, and observe the order.
  3. Open a page using type="module" from the file system and read the console error, then serve it over localhost.
  4. Compare first paint in the Network panel with a blocking script and with a deferred one.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All HTML notes →
HTML

Forms and JavaScript

Read values, submit without a page reload, validate with the built in API, and handle errors accessibly. All of it built on a real form element.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.