NodeJSTop-Level await

Top-Level await

Top-level await lets you use the await keyword directly at the root of an ES module — outside of any async function. Before Node 14.8, every await had to be inside an async function, leading to the (async () => { ... })() IIFE pattern. Top-level await is only available in ES modules (files with .mjs extension or "type": "module" in package.json) — it does not work in CommonJS.

Before and after

TS
// ❌ Before: async IIFE wrapper required in CommonJS or older ESM:
(async () => {
  const config = await loadConfig()
  const db = await connectDatabase(config)
  const app = createApp(db)
  app.listen(3000)
})()

// ✅ After: top-level await in an ES module (.mjs or "type":"module"):
const config = await loadConfig()
const db = await connectDatabase(config)
const app = createApp(db)
app.listen(3000)
Top-level await is only available in ES modules — it does not work in .js files that use CommonJS (require/module.exports)
Top-level await is part of the ES module specification. A CommonJS module (`.js` file with `require()`) cannot use top-level await. An ES module (`.mjs` file, or `.js` with `"type": "module"` in the nearest `package.json`) can. In TypeScript, set `"module": "ESNext"` or `"NodeNext"` in `tsconfig.json` and use `.mts` files or configure `"type": "module"`. The other requirement is that the **entire module graph** must be ES modules — you can't top-level await in an ESM file that's being imported by a CommonJS file.
ES module configuration

JSON
// package.json — enable ESM for .js files:
{
  "type": "module"
}

// tsconfig.json — configure TypeScript for Node ESM:
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022"
  }
}
Common use cases

TS
// 1. Startup initialization:
import { createPool } from 'pg'
const pool = new createPool({ connectionString: process.env.DATABASE_URL })
await pool.query('SELECT 1')   // verify connection before serving traffic
console.log('Database connected')

// 2. Dynamic import based on runtime config:
const { default: config } = await import(`./config.${process.env.NODE_ENV}.js`)

// 3. Loading JSON data files (useful for fixtures or seed data):
import { readFile } from 'node:fs/promises'
const seedData = JSON.parse(await readFile('./seed-data.json', 'utf8'))

// 4. Conditional feature loading:
const adapter = process.env.STORAGE === 's3'
  ? await import('./storage/s3.js')
  : await import('./storage/local.js')
Module loading order — how it works

Text
When a module uses top-level await, it BLOCKS its importing module until resolved:

// a.mjs
export const value = await fetchConfig()   // blocks

// b.mjs
import { value } from './a.mjs'
// b.mjs can't execute until a.mjs's top-level await resolves

// main.mjs
import './b.mjs'    // waits for b → waits for a
// main.mjs continues after all awaits in the import graph resolve
Top-level await in a shared module blocks every module that imports it — a slow or failing await in a dependency delays your entire application startup
When a module with top-level await is imported, the importer **waits**. If your shared database module does `await pool.connect()` at the top level and the database takes 5 seconds to respond, every module that imports the database module waits 5 seconds before it can execute. This is usually fine for initialization (you want to fail fast on a bad connection), but be careful with top-level awaits that might hang indefinitely — add timeouts. Also note that a **rejected** top-level await causes the module to fail to load, and importing code receives an error at import time rather than gracefully catching it. Always use try/catch around awaits that might fail, and exit the process on unrecoverable startup errors.
Error handling

TS
// Top-level try/catch for startup errors:
try {
  const db = await connectDatabase()
  const app = await createApp(db)
  app.listen(3000, () => console.log('Server started'))
} catch (err) {
  console.error('Failed to start server:', err)
  process.exit(1)   // exit with error code so the process supervisor restarts
}

// Or: let it propagate — unhandled top-level await rejection crashes the process
// with exit code 1 (same as unhandledRejection), which is often the right behavior
// for startup failures: fail loudly so the orchestrator restarts.
Top-level await in TypeScript + ts-node/tsx

Bash
# tsx handles top-level await natively (it targets ESNext):
tsx src/server.ts

# ts-node requires ESM mode:
ts-node --esm src/server.ts
# or: NODE_OPTIONS="--loader ts-node/esm" node src/server.ts

JSON
// tsconfig.json — top-level await requires ES2022+ target:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}
Next
Restart your Node process automatically when files change with the built-in watch mode: [Built-in Watch Mode (--watch)](/nodejs/watch-mode).