Using await at the top level in ES modules

2 min read

Writing asynchronous code in JavaScript used to come with a limitation: the await keyword could only be used inside an async function. That changed when ES2022 introduced top-level await, which lets you use await at the top of an ES module.

What’s top-level await?

Traditionally await was only valid inside async functions:

// ❌ SyntaxError outside async function
const result = await fetchData();

Top-level await now makes this valid at the top level of ES modules:

// ✅ This works in an ES module
const result = await fetchData();

In the past, you’d need to wrap this logic in an async immediately invoked function expression (IIFE) just to use await at the top level:

(async () => {
  const result = await fetchData();
})();

With top-level await, you can drop that boilerplate. The async code lives right in the module’s top scope, which is simpler and easier to read.

Where it comes in handy

Fetching remote configuration at startup

const config = await fetch('/config.json').then(res => res.json());
initializeApp(config);

No nesting or wrapping, just straightforward async logic.

Dynamic imports before running code

const dbDriver = await import('./drivers/postgres.js');
await dbDriver.connect();

Top-level await pairs naturally with dynamic import() for conditionally loading code based on runtime factors.

import { settings } from './config.js'; // Waits for config.js to finish evaluating

Use dynamic import() when you need to load modules asynchronously at runtime.

Waiting for initialization (e.g., WebAssembly)

const wasm = await WebAssembly.instantiateStreaming(fetch('lib.wasm'));
wasm.instance.exports.main();

In performance-critical codebases you can now await WASM modules without async wrappers.

const response = await fetch('lib.wasm');
const buffer = await response.arrayBuffer();
const wasm = await WebAssembly.instantiate(buffer);

Constraints and gotchas

Module execution is paused

When a module uses top-level await, any modules that import it will pause execution until the awaited code completes:

// config.js
export const settings = await fetchSettings();

// main.js
import { settings } from './config.js'; // waits for config.js to finish

This pauses module evaluation, not the entire application. But it can still introduce hidden load-order dependencies if not managed carefully.

Cyclic dependencies can break

Top-level await can cause runtime errors in cyclic dependencies if both modules depend on each other’s awaited exports during evaluation:

Error: Circular dependency involving module with top-level await

Browser and runtime support

Top-level await is supported in all modern browsers and in Node.js:

  • Node.js v16+: fully supported in .mjs or in .js files with "type": "module"
  • Dedicated Web Workers: supports top-level await when launched as a module (type: "module"):

    new Worker('worker.js', { type: 'module' });
    

Keep in mind that top-level await only works in ES modules:

  • Files with the .mjs extension, or
  • .js files when "type": "module" is specified in your package.json

Babel can’t transpile top-level await on its own because it lacks control over module execution order. However, modern bundlers like Vite, Webpack (5+), or Rollup can support it through native ES module output, dynamic imports, and code splitting.

Best practices

A rough guide:

Use case Recommendation
Fetch remote config ✅ Good use case
Load feature modules ✅ Great with await import()
Computation-heavy logic 🚫 Move to a background thread or worker
Public/shared modules ⚠️ Avoid using it to prevent dependency delays
Large apps with shared deps ⚠️ Be careful with circular imports

If your module is meant to be reused (like a library or shared utility), avoid top-level await to prevent blocking downstream consumers. Even a small delay in a shared module can cascade and slow down multiple parts of an application.

Want to try it?

Create a .mjs file in Node.js or add type="module" to your <script> tag in HTML:

<script type="module">
  const result = await fetch('/api/data');
  console.log(result);
</script>

Make sure your site is served over HTTPS (or localhost) for fetch and module scripts to work correctly in modern browsers.

I’d use it for app startup and one-off scripts, and skip it in libraries or shared modules that others depend on.