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.