skip to content

Why is `await` outside any function a SyntaxError in a CommonJS file or a classic `<script>`, but legal in an ES module?

level: juniorimportance: should knowfreq 48%

answer

  1. two parse goals, not one
  2. await is a keyword only in modules
  3. require returns synchronously
  4. no way to suspend a require
  5. async IIFE is not the same thing

basics

~20 s

Top-level await exists only in the ES module goal, added in ES2022. A classic script or a CommonJS file is parsed as a script, where await is not a keyword outside an async function, and CommonJS loading is synchronous so a module could not suspend anyway.

solid answer

~50 s

Top-level `await` is a property of the ES module goal, not of JavaScript source in general. Source parsed as a classic script — a `<script>` without `type="module"`, or a CommonJS file — treats `await` as a keyword only inside an `async` function, so at the top level it is a SyntaxError. That restriction is not arbitrary. CommonJS loading is synchronous by design: `require(...)` runs the target file to completion and hands back its exports as the return value of a normal function call, with no way to suspend mid-load and resume the caller later. ES modules can offer it because module evaluation was already defined as a phase the host drives and can await. The usual workaround in a CommonJS file is an async IIFE, or a dynamic `import()` — but neither makes the importer wait the way real top-level await does.

code

javascript · 4 lines
javascript
// Valid only when this file is evaluated as an ES module.
const value = await Promise.resolve(42);
console.log(value);
export default value;

go deeper

for a junior

Know that top-level await belongs to ES modules only, and that the same line is a SyntaxError in a classic script or a CommonJS file. Be able to say that CommonJS loading is synchronous.

for a middle

Explain the two parse goals and why the grammar differs, then give the loading reason: require returns exports as the value of a synchronous call, leaving nowhere to suspend. Note that an async IIFE is not an equivalent substitute.

for a senior

Show the downstream consequences you have actually hit: a module using top-level await cannot be consumed synchronously, which constrains how a library can be packaged and which output formats it can ship. Be ready to recognize the SyntaxError as a signal that a file is being treated as a script.

for a principal

Own the packaging call: adopting top-level await in a published module removes it from every synchronous consumption path, so decide deliberately whether that is acceptable for your audience or whether the async work belongs behind an exported initializer instead.

## Two parse goals, two answers The same characters in a `.js` file mean different things depending on how the file is parsed. JavaScript has two top-level parse goals: Script and Module. A `<script>` tag without `type="module"` is parsed as a Script. A file loaded as an ES module is parsed as a Module. This single decision changes several things at once: module source is strict-mode by default, `import` and `export` are only grammatical in the Module goal, and, since ES2022, `await` is permitted at the top level of the Module goal only. In the Script goal, `await` is not a reserved word at the top level. It is contextually a keyword inside an `async` function and otherwise just an identifier. That is why old code could legally write `var await = 1;` in a script. Because the token is not an operator there, `await somePromise;` at the top of a classic script is a SyntaxError, and adding a newer engine does not change it — the grammar itself differs by goal. ```javascript // classic script: SyntaxError await Promise.resolve(1); // ES module: fine, and the module's evaluation suspends here await Promise.resolve(1); ``` ## Why CommonJS cannot simply add it CommonJS files are parsed with the Script grammar (wrapped in a function that supplies `require`, `module`, and `exports`), so the grammatical answer already applies. But the deeper reason is the loading model. `require('./config')` is an ordinary synchronous function call. It locates the file, runs its body to completion, and returns `module.exports` as the call's return value. The caller is sitting in the middle of an expression, on the stack, waiting for a value. For a CommonJS module to await something, that `require` call would have to suspend the caller and resume it later — which would mean turning every `require` in the program into an asynchronous operation, and turning every function that contains one into something that can suspend. That is a different language, not an added feature. Nothing in the synchronous call-and-return shape of `require` leaves room for it. ES modules are different because the host, not user code, drives loading. Fetching, parsing, linking, and evaluating a module graph are separate phases the host performs, and evaluation was already specified so that the host learns when it is done. Making that completion asynchronous fit the existing shape: nothing in user code has to sit on the stack waiting for it. Instead, the module system holds back the importers' evaluation. ## What people write instead In a CommonJS file, the common substitutes are: ```javascript // async IIFE — the file finishes loading immediately (async () => { const data = await loadData(); // ... })(); ``` and `import('./esm-module.js')`, which returns a promise for the module namespace and works from CommonJS. Both let you await *something*, but neither reproduces the real behaviour: with an IIFE the module's own load completes right away, so anything that required it proceeds immediately and may read exports that have not been assigned. The distinguishing feature of true top-level await is that the module system delays the importers, and no user-land pattern in a synchronous loader can do that. ## Practical consequences A few follow from this cleanly. A module that uses top-level await cannot be consumed by a synchronous loader at all — there is no correct value to return from a synchronous call for a module that has not finished evaluating, so runtimes that let synchronous code pull in an ES module refuse when the graph contains top-level await. Build outputs matter for the same reason: a bundle emitted in a non-ESM format has no way to express a module that suspends partway through, so the feature constrains which output formats a library can ship. And a file that uses it is unambiguously an ES module — if a tool or runtime is treating your file as a script, the very first symptom is often a SyntaxError on the `await` line, which is a useful diagnostic rather than a nuisance.

  • Does an async IIFE in a CommonJS file give you the same guarantee as top-level await?
    No. The IIFE only schedules work; the file finishes loading immediately, so whoever required it proceeds at once and can read exports before the async work assigns them. Top-level await instead delays the importing modules until the value exists. The IIFE keeps the race in your code, and you have to defend against it with a promise export or an explicit ready check.
  • Can code in a CommonJS file still load an ES module that uses top-level await?
    It can use dynamic `import()`, which returns a promise for the module namespace and settles only after the module graph — including its top-level await — has finished evaluating. What it cannot do is pull that module in synchronously: there is no correct value to return for a module that has not finished, so synchronous loading of a graph containing top-level await is rejected.

saying these in an interview costs you the question

  • Says any .js file supports top-level await in modern engines
  • Claims require() could just be made to wait
  • Thinks an async IIFE is an equivalent replacement
  • Assumes it is a bundler feature rather than a language one
  • Says the restriction is only about strict mode

context