How to Use Top-Level Await in Node.js 14 LTS

At last, it's here! Node.js v14 is now officially in Long Term Support as of October, which means the long-awaited top-level await functionality is now ready for production use – a feature that JavaScript developers have been requesting for years.

What is top-level await?

If you've been developing in Node.js for any length of time, you'll be painfully familiar with this error:

SyntaxError: await is only valid in async function

In the past, whenever you wanted to use await to handle a promise, you had to wrap it in an async function. This often led to awkward patterns like using an immediately invoked async function expression:

// Old way - wrapping with an async IIFE 😒
(async () => {
  const response = await fetch('https://api.example.com/data');
  const data = await response.json();
  console.log(data);
})()
.catch(err => console.error('Oops:', err));

This was always a bit clunky, especially for scripts where all you wanted to do was make a few API calls and process the data. But now, we can simply do this:

// New way - clean top-level await! 🎉
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);

Clean, simple, and much more intuitive!

Why is this important?

Top-level await fundamentally changes how we write Node.js scripts. It allows for more linear, readable code flows, especially in scripts that need to perform asynchronous operations like API calls, database queries, or file system operations.

Imagine a scenario where you're writing a utility script that needs to:

  1. Fetch some data from an API
  2. Transform that data
  3. Write it to a file
  4. Send a confirmation email

Previously, you'd need to either nest these operations in callbacks, chain promises, or wrap everything in an async IIFE. Now, you can simply write:

// Fetch data
const response = await fetch('https://api.example.com/data');
const data = await response.json();

// Transform data
const transformed = transformData(data);

// Write to file
await fs.promises.writeFile('output.json', JSON.stringify(transformed, null, 2));

// Send confirmation
await sendEmail('admin@example.com', 'Data processing complete');

console.log('All done!');

This makes scripts more maintainable, easier to reason about, and more approachable for developers new to asynchronous JavaScript.

Module-level implications

One of the coolest aspects of top-level await is how it affects module imports. When you use top-level await in a module, any other module that imports it will wait for the awaited promises to resolve before they can proceed with their execution.

For example, you can now do this in a module:

// database.js
export const connection = await createDatabaseConnection();

Any module that imports this will pause execution until that database connection is established:

// app.js
import { connection } from './database.js';
// This code won't run until the connection is established

console.log('Database ready:', connection.status);

This gives us powerful ways to ensure dependencies are properly initialized before any code that relies on them executes. No more complicated initialization patterns or race conditions!

Final thoughts

Top-level await is a seemingly small change that has big implications for how we structure Node.js applications. It makes asynchronous code more approachable and can lead to cleaner, more maintainable scripts.

Now that Node.js 14 is in LTS status, you can confidently use this feature in your production applications. It's one of those quality-of-life improvements that might seem minor, but once you start using it, you'll wonder how you ever lived without it!