Skip to content

Top-level Await

This lesson explains Top-level Await with clear examples, use cases, and best practices so you can use modern JavaScript confidently in real projects.

ES6+ Top-level Await Overview

Top-level await allows you to use the await keyword at the top level of an ES module, outside any async function. This makes it easier to load configuration, fetch data, and initialize modules without wrapping everything in an async IIFE.

Top-level await pauses execution of the current module until the awaited promise settles. Modules that import it will wait until initialization completes, which is useful for setup logic but requires careful design in larger applications.

Feature Description
Introduced In ES2022
Works In ES modules only
Syntax await promise at module scope
Module Behavior Blocks dependent imports until settled
Common Uses Config loading, data bootstrapping, module init
Not Supported In Classic non-module scripts

Basic Top-level Await Example

// config.js

const response =
  await fetch("/api/config");

const config =
  await response.json();

export default config;
// app.js

import config from "./config.js";

console.log(config.appName);
console.log(config.theme);

In this example, config.js fetches data before exporting it. Any module that imports config.js waits until the fetch and JSON parsing finish.

How Top-level Await Works

When a module uses top-level await, the JavaScript module loader treats that module as asynchronous. Importers pause until the awaited work completes and the module finishes evaluating.

Step Description Example Syntax
Use ES Module Top-level await requires module context. <script type="module">
Await Promise Pause module evaluation at top level. await fetch("/api/users")
Export Result Share initialized values with importers. export default data
Import Module Dependent modules wait for completion. import data from "./data.js"
Handle Errors Catch failed async initialization. try / catch
Run App Logic Use loaded config or data safely. console.log(config.theme)

Use Top-level Await in a Module Script

<script type="module">
  const response =
    await fetch("/api/profile");

  const profile =
    await response.json();

  document
    .querySelector("#username")
    .textContent = profile.name;
</script>

Browser module scripts can use top-level await directly when loaded with type="module". Regular classic scripts cannot use this syntax.

Load a Module with Dynamic Import

// logger.js

const { createLogger } =
  await import("./logger-factory.js");

export const logger =
  createLogger("app");
// main.js

import { logger } from "./logger.js";

logger.info("Application started.");

Top-level await works well with dynamic imports when a module needs to choose or load dependencies asynchronously during initialization.

Handle Top-level Await Errors

// settings.js

let settings;

try {
  const response =
    await fetch("/api/settings");

  if (!response.ok) {
    throw new Error("Settings request failed.");
  }

  settings =
    await response.json();
} catch (error) {
  console.error(error.message);

  settings = {
    theme: "light",
    language: "en"
  };
}

export default settings;

Wrap top-level await in try/catch when failed network requests or initialization errors should not break the entire app. Provide fallback values when async setup fails.

Top-level Await vs Async IIFE

// Old pattern

(async () => {
  const response =
    await fetch("/api/config");

  const config =
    await response.json();

  startApp(config);
})();
// Top-level await pattern

const response =
  await fetch("/api/config");

const config =
  await response.json();

export default config;

Before top-level await, developers often used async immediately invoked function expressions. Top-level await makes module initialization cleaner and easier to import elsewhere.

Module Dependency Chain Example

// database.js

const connection =
  await connectToDatabase();

export { connection };
// users-service.js

import { connection } from "./database.js";

export async function getUsers() {
  return connection.query("SELECT * FROM users");
}
// app.js

import { getUsers } from "./users-service.js";

const users =
  await getUsers();

console.log(users);

If database.js uses top-level await, modules that depend on it automatically wait for the database connection before continuing execution.

Await Multiple Promises in Parallel

const [usersResponse, settingsResponse] =
  await Promise.all([
    fetch("/api/users"),
    fetch("/api/settings")
  ]);

const users =
  await usersResponse.json();

const settings =
  await settingsResponse.json();

export {
  users,
  settings
};

Top-level await does not mean requests must run one after another. Use Promise.all() when independent async tasks can run in parallel during module initialization.

Top-level Await Limitations

  • Works only inside ES modules, not classic scripts.
  • Can delay modules that import the awaiting module.
  • May complicate startup time if overused at the root level.
  • Not available in older environments without modern module support.
  • Can hide initialization failures if errors are not handled clearly.
  • Should be used carefully in shared library modules.

Common Top-level Await Use Cases

  • Loading application configuration before startup.
  • Fetching initial data for dashboard or admin modules.
  • Initializing database or service connections.
  • Choosing runtime features with dynamic imports.
  • Preparing module-level constants from async sources.
  • Bootstrapping browser apps with module scripts.

Top-level Await Best Practices

  • Use top-level await only in ES modules.
  • Keep module initialization focused and fast.
  • Handle errors with try/catch and sensible fallbacks.
  • Use Promise.all() for independent async tasks.
  • Avoid long blocking chains in widely imported modules.
  • Export initialized values clearly from setup modules.
  • Measure startup impact in production applications.

Common Top-level Await Mistakes

  • Using top-level await in a non-module script.
  • Assuming importers can run before async initialization finishes.
  • Putting too much startup logic in one root module.
  • Not handling failed fetch or initialization errors.
  • Running independent requests sequentially without need.
  • Using top-level await in library code without documenting the delay.
  • Replacing all async functions with top-level await unnecessarily.

Top-level Await vs Async Functions

Feature Top-level Await Async Function
Location Module top level. Inside a function.
Best For Module initialization. Reusable async logic.
Import Behavior Blocks dependent modules. Caller decides when to await.
Typical Use Load config once at startup. Fetch data on demand.

Key Takeaways

  • Top-level await lets modules use await outside async functions.
  • It works only in ES modules and pauses dependent imports until setup completes.
  • It simplifies bootstrapping compared with async IIFE patterns.
  • Use error handling and parallel requests thoughtfully during initialization.
  • Prefer async functions for reusable runtime logic, and top-level await for module setup.

Pro Tip

Use top-level await for one-time module setup such as config, constants, or connection initialization. Keep repeated async work inside normal async functions instead.