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

  1. ESM Setup

  2. Example

    // index.mjs
    import { readFile } from 'fs/promises';
    
    const content = await readFile('./data.json', 'utf-8');
    console.log(JSON.parse(content));
    
  3. CommonJS Caveat

    • Top-level await is not supported in CommonJS modules (.cjs), and attempting it will throw ERR_REQUIRE_ASYNC_MODULE (GitHub).

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-level try/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);
}
  1. Run with node cli.mjs.
  2. 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.