skip to content

How does <script type="module"> differ from a classic <script src> in when it executes, and what does adding the async attribute to a module script change?

level: middleimportance: should knowfreq 55%

answer

  1. one attribute changes the loading contract
  2. no parser-blocking form exists here
  3. the redundant attribute and the meaningful one
  4. inline behaves differently from a classic inline script

basics

~20 s

A module script is deferred by default: it downloads in parallel and executes after the document is parsed, in document order, before DOMContentLoaded. Adding async makes it run as soon as it is ready, and async works on inline modules too.

solid answer

~40 s

`<script type="module">` never blocks the parser. Module scripts behave as if they had `defer`: the browser fetches them in parallel, then executes them after the document is fully parsed, in the order the tags appear, before `DOMContentLoaded` fires. Writing `defer` on a module is redundant and has no effect. Adding `async` opts out of that: the module runs as soon as its graph has loaded, in no guaranteed order — and unlike a classic script, `async` also applies to an *inline* `<script type="module">`, not just to one with `src`. Two other module-specific facts matter for markup: the same module URL is evaluated only once no matter how many tags or imports reference it, and module scripts are always fetched in CORS mode, so a cross-origin module needs the serving origin to allow it.

code

html · 8 lines
html
<!-- deferred by default: runs after parsing, in this order -->
<script type="module" src="/lib.js"></script>
<script type="module" src="/app.js"></script>

<!-- async is honoured even inline: runs as soon as it is ready -->
<script async type="module">
  console.log('may run before the document finishes parsing');
</script>

go deeper

for a junior

Know that type="module" scripts never block parsing and run after the HTML is parsed, so code inside one can safely reach elements on the page without waiting for an event.

for a middle

Explain that modules are deferred by default, that defer is therefore redundant, and that async opts out of ordering and — unlike on a classic script — is honoured on an inline module too.

for a senior

Bring in the operational differences: one evaluation per module URL, CORS-mode fetching that can break a cross-origin dependency that worked as a classic tag, and strict MIME-type enforcement that fails silently on a misconfigured host.

for a principal

Own the migration decision: what it costs to move a codebase's script tags to modules, which third-party tags must stay classic because their origins send no cross-origin permission, and how you keep loading behaviour predictable across a large page template.

## Modules are deferred, by definition When you write `type="module"` on a `<script>`, you change more than the dialect the file is parsed in — you change its loading contract. A module script is *always* deferred. There is no parser-blocking variant. Concretely: ```html <head> <script type="module" src="/app.js"></script> </head> ``` The browser sees the tag early, starts fetching `/app.js` (and, recursively, everything it imports) in parallel with parsing, and executes nothing until the document has been fully parsed. Then module scripts run in document order, and `DOMContentLoaded` fires afterwards. That is exactly the timing `defer` gives a classic script — which is why `defer` on a module tag is a no-op, and why module code can safely query the DOM without waiting for an event. The same is true of an inline module: ```html <script type="module"> document.querySelector('main').dataset.ready = 'true'; </script> ``` An inline *classic* script runs immediately, right where it sits, with only the markup above it in the DOM. An inline *module* waits for the whole document. This trips people up: identical-looking tags run at completely different moments depending on one attribute. ## What async changes `async` on a module means "run as soon as the module and its dependency graph have finished loading", giving up both the wait for parsing and the document-order guarantee. The important asymmetry against classic scripts: `async` is honoured on an inline module. A classic inline script ignores `async` entirely, but this is meaningful and runs as early as possible: ```html <script async type="module"> import { track } from '/telemetry.js'; track('page-open'); </script> ``` Use it the same way you use `async` on a classic script: for code with no ordering relationship to anything else on the page. ## Ordering across the two kinds Deferred classic scripts and module scripts share one queue in document order, so a `defer` classic tag written before a module tag runs before it. Mixing the two is legal but rarely worth reasoning about; teams normally standardise on modules for first-party code. ## Evaluated once Every module URL a page loads is recorded in the module map. If two tags point at the same URL, or five modules import the same helper, the file is fetched once and its top-level code is evaluated once. Classic scripts have no such rule: two identical `<script src>` tags download (subject to HTTP caching) and execute twice, running any side effects twice. ## CORS and credentials Module scripts are always fetched in CORS mode. A classic `<script src>` pointing at another origin loads without any cross-origin permission (which is why third-party tags work at all), but a cross-origin module requires the responding server to send permission headers, or the fetch fails and the module never runs. The `crossorigin` attribute controls whether credentials are sent. In markup terms: moving a cross-origin dependency from a classic tag to a module tag can break it, and the fix lives on the serving origin, not in your HTML. ## MIME type strictness A module is rejected unless it is served with a JavaScript MIME type. A classic script is far more forgiving. A module served as `text/plain` from a misconfigured static host simply does not execute — a silent-looking failure that shows up only in the console. ## nomodule interplay Because older engines do not recognise `type="module"`, they skip such tags entirely — which is what makes the module/`nomodule` fallback pair possible. That pattern is mostly historical now, but it is why `type="module"` was designed to be ignored rather than to error. ## How to answer Lead with the timing: modules are deferred by default, run in order after parsing, before `DOMContentLoaded`; `defer` adds nothing, `async` opts out and uniquely also works inline. Then add the two facts a classic script does not have — evaluated once per URL, and fetched with CORS rules.

  • If you write defer on a <script type="module">, what happens?
    Nothing. The attribute is ignored because module scripts already defer by default — they fetch in parallel and execute after parsing, in document order. It is harmless but noise, and it can mislead a reader into thinking the module would otherwise block the parser, which it never would.
  • Why can an inline <script type="module"> not read an element that an inline classic script above it just created?
    Order of execution, not scope. The inline classic script runs immediately during parsing; the inline module waits until the whole document is parsed. So the module runs later, not earlier — it will in fact see that element. The reverse is the real trap: a classic inline script below a module tag still runs first.
  • Two <script type="module" src="/init.js"> tags appear on the same page. How many times does the file's top-level code run?
    Once. The module map keys loaded modules by resolved URL, so the second tag reuses the already-evaluated module rather than re-running it. Two identical classic `<script src>` tags would execute the file twice, running its side effects twice — a real difference when the file registers listeners or mutates globals.

saying these in an interview costs you the question

  • Says modules block the parser like classic scripts
  • Adds defer to a module and expects a change
  • Thinks async is ignored on inline module scripts
  • Believes a module URL re-runs per script tag
  • Expects cross-origin modules to load without server permission

context