4 minutes
Leveraging Top-Level await
in Node.js
Summary
Node.js v18, released on April 19, 2022, brings first-class ES-module support for top-level await
, allowing you to write asynchronous code at the module root without wrapping it in an async function. This feature, unflagged since v14.8.0, simplifies build and utility scripts, interactive REPL experimentation, and dynamic imports by enabling await
at module scope. However, because any module depending on one with a top-level await
must wait for its resolution, startup time can increase and bundlers or CommonJS interop may surface new errors. In this post, we’ll cover how to enable and use top-level await
, explore its benefits and pitfalls, and walk through a practical example.
1. Introduction
Before top-level await
, asynchronous code at the module root required wrapping everything in an immediately-invoked async function expression (IIFE) or defining an async
function just to call it. This boilerplate obscured intent and made simple scripts feel verbose. Top-level await
now lets you write synchronous-looking code that reads naturally:
// Before (IIFE)
(async () => {
const data = await fetchData();
console.log(data);
})();
// After (ESM + top-level await)
const data = await fetchData();
console.log(data);
2. What Changed in Node.js v18
Release & Context
Node.js v18 entered “Current” status on April 19, 2022 and was slated to become LTS (“Hydrogen”) in October 2022 (Node.js , Run JavaScript Everywhere). Alongside other modern web APIs like global fetch
, it cemented ES-module capabilities for production use (Stefan Judis).
ES-Module & Top-Level Await
The await
keyword has been allowed at the top level of ECMAScript modules since v14.8.0; v18 simply continues that unflagged support across all ES modules (Node.js , Run JavaScript Everywhere). You can now omit async
wrappers in any .mjs
file or when "type": "module"
is set in package.json
(Node.js , Run JavaScript Everywhere).
3. Enabling & Using Top-Level await
ESM Setup
- Rename scripts to
.mjs
, or add"type": "module"
in yourpackage.json
. (Node.js , Run JavaScript Everywhere)
- Rename scripts to
Example
// index.mjs import { readFile } from 'fs/promises'; const content = await readFile('./data.json', 'utf-8'); console.log(JSON.parse(content));
CommonJS Caveat
- Top-level
await
is not supported in CommonJS modules (.cjs
), and attempting it will throwERR_REQUIRE_ASYNC_MODULE
(GitHub).
- Top-level
4. Benefits & Use Cases
- Cleaner Build & Utility Scripts One-off tasks like data migrations or file transformations become straightforward without async wrappers (Amazon Web Services, Inc.).
- Interactive REPL
Experiment in the Node REPL without boilerplate, as v18’s REPL now better handles unwrapped
await
(Stefan Judis). - Dynamic Imports
Use
await import('./module.js')
directly, improving readability over.then()
chains (Gist).
5. Caveats & Pitfalls
- Blocking the Module Graph
Any module importing one with a top-level
await
will block until its promise resolves, potentially delaying application startup (Gist, Node.js , Run JavaScript Everywhere). - Bundler Compatibility
Some bundlers (e.g., esbuild) historically struggled with ESM top-level
await
, requiring specific flags or workarounds (GitHub). - CommonJS Interop
Mixing CJS and ESM can surface
ERR_REQUIRE_ASYNC_MODULE
errors if a CommonJS require pulls in an awaited ES module (GitHub).
6. Best Practices
- Use Sparingly at Entry Points
Confine top-level
await
to your application’s main entry file to limit the blocked graph radius. - Handle Errors Gracefully
Wrap your
await
calls in top-leveltry/catch
to prevent unhandled promise rejections. - Isolate in ESM-Only Code Keep purely ES-module directories separate from legacy CJS code if you rely heavily on this feature.
7. Full Example & Walkthrough
Below is a small CLI tool using only top-level await
to fetch remote JSON, process it, and write results to disk.
// cli.mjs
import { writeFile } from 'fs/promises';
import fetch from 'node-fetch';
try {
const res = await fetch('https://api.example.com/items');
const items = await res.json();
const titles = items.map(i => i.title).join('\n');
await writeFile('titles.txt', titles);
console.log('Saved', items.length, 'titles.');
} catch (err) {
console.error('Error:', err);
process.exit(1);
}
- Run with
node cli.mjs
. - Notice no async IIFE is needed,
await
works at the top level.
8. Conclusion & Further Reading
Top-level await
in Node.js v18 streamlines asynchronous code in ES modules, cutting boilerplate and enhancing readability. Yet, it introduces module-graph blocking and interoperability considerations. For more details, check out the official Node.js ESM guide and the TC39 proposal on top-level await
.
- Node.js ESM Docs (Node.js , Run JavaScript Everywhere)
- TC39 Top-Level Await Proposal: https://github.com/tc39/proposal-top-level-await
- Node.js v18 Release Notes (Node.js , Run JavaScript Everywhere)