Using await at the top level in ES modules
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
: a modern ES module feature that enables new patterns for asynchronous code at the module level.
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 say goodbye to boilerplate like this. Your asynchronous code can now live directly in the module’s top scope, making things simpler, cleaner, and easier to read.
Real-world use cases
Fetching remote configuration at startup
const config = await fetch('/config.json').then(res => res.json());
initializeApp(config);
No more 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.
⚠️ Note: Static import
statements are resolved before module execution. If an imported module contains top-level await
, the importing module will wait for the awaited code to finish executing before it starts.
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.
⚠️ Note: Make sure your server serves .wasm
files with the correct application/wasm
MIME type, otherwise instantiateStreaming()
will fail. In that case, fall back to:
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 yourpackage.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
Here’s how to use top-level await
responsibly:
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 |
Pro tip: 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.
Top-level await
gives you cleaner, more direct async code in modules. But it comes with trade-offs around blocking and module dependencies. Use it where it simplifies logic, and avoid it in libraries or shared modules that others depend on.