Skip to content

How to Use Top-Level Await in Modern JavaScript Modules

Learn how to use top-level await in JavaScript modules for asynchronous initialization, dynamic imports, configuration loading, browser compatibility, and safer web app startup.

How to Use Top-Level Await in Modern JavaScript Modules

On this page

Safari 27 is fixing a subtle problem that made top-level await less trustworthy than it should have been. WebKit says Safari 27 now has full specification compliance after its module loader was rebuilt, resolving cases where imports could finish in the wrong order and trigger β€œaccessed before initialization” errors. That makes this a useful time to revisit top-level await, especially if your web application loads configuration or data before its modules begin running.Β 

What top-level await actually changes

Normally, await is used inside an asynchronous function, which pauses that function until a Promise settles. Top-level await extends the same idea to an ECMAScript module, allowing the module itself to pause while asynchronous work completes. A module that depends on it waits for that evaluation to finish, while unrelated sibling modules can continue loading. This makes asynchronous initialization possible without wrapping an entire module in an immediately invoked asynchronous function.Β 

The distinction matters because a regular script and a module do not follow the same rules. A normal script cannot use await directly at its top level, while a script loaded with type="module" can. If you put top-level await in the wrong kind of script, the browser reports a syntax error instead of treating the file as an asynchronous module.Β 

Turn your browser script into a module

Start with a small HTML page containing a module script. The key change is type="module"; without it, the browser parses the file as a classic script and top-level await is not permitted.




  
  Top-Level Await Demo


  

Top-Level Await Demo

Create a file named main.js beside the HTML file. Because the browser is loading it as a module, you can now use await directly in the module body. This is the first checkpoint: if the page loads without a syntax error, the browser is treating your JavaScript as a module.Β 

Load data before the rest of the module runs

A practical use for top-level await is asynchronous initialization. Imagine that your application needs a configuration object before it creates its main interface. Instead of defining an asynchronous function solely to perform that startup request, the module can fetch the data and wait for it directly.

const response = await fetch("config.json");

if (!response.ok) {
  throw new Error(`Configuration request failed: ${response.status}`);
}

const config = await response.json();

console.log("Application configuration:", config);

Here the first await waits for the network request to produce a response, and the second waits for the response body to be decoded as JSON. Code appearing after those statements does not run until the required configuration has been loaded. That can make initialization easier to read because the dependency is visible at the point where it is needed rather than hidden inside a separate startup function.Β 

Move initialization into a separate module

The bigger advantage appears when the asynchronous module is imported by another module. Create config.js and put the loading logic there. Export the finished configuration only after the asynchronous operation has completed.

const response = await fetch("config.json");

if (!response.ok) {
  throw new Error(`Configuration request failed: ${response.status}`);
}

const config = await response.json();

export default config;

Your main module can then import the configuration normally.

import config from "./config.js";

console.log("API endpoint:", config.apiEndpoint);
console.log("Theme:", config.theme);

The important behavior is the dependency relationship. The module importing config.js cannot finish evaluating before the awaited configuration module has completed, so the imported value is ready when the dependent code uses it. At the same time, modules elsewhere in the dependency graph that do not depend on config.js can continue their own work. That is one of the central reasons top-level await is more useful than simply making the entire application startup function asynchronous.Β 

Use it with dynamic imports when features are optional

Top-level await also fits applications that decide which code to load after an initial asynchronous check. JavaScript's dynamic import() returns a Promise for a module, so a module can wait for that import before continuing.

const settings = await fetch("settings.json")
  .then((response) => response.json());

let editor;

if (settings.editor === "advanced") {
  editor = await import("./advanced-editor.js");
} else {
  editor = await import("./basic-editor.js");
}

editor.start();

This pattern keeps the decision close to the initialization that depends on it. The browser does not need to load both editor implementations simply because the application might eventually use either one. Dynamic imports are also available in non-module script environments, although top-level await itself still requires a module context.Β 

Do not confuse module waiting with blocking the browser

One common misunderstanding is that top-level await freezes the entire browser while a request is running. That is not what happens. The module's evaluation is paused, while other work that is not dependent on that module can continue, and JavaScript's event loop remains available for other tasks. The important cost is dependency waiting: modules that depend on the paused module cannot finish their own evaluation until it resolves.Β 

This means you should not put every network request at the top level simply because the syntax is convenient. If an application can render useful content before optional data arrives, delaying a dependency chain may make startup less responsive. Top-level await works best when the awaited result is genuinely required before the importing module can do its job.

Understand the Safari 27 change before shipping

Top-level await is not a new JavaScript feature; it was standardized as part of ECMAScript 2022 and is already supported by modern browsers. The recent Safari change is about correctness rather than introducing the syntax itself. WebKit says its older module loader was built around an earlier loader proposal and that this foundation caused subtle ordering and initialization problems when asynchronous module evaluation was introduced.Β 

WebKit demonstrated the problem with multiple imports of a module containing top-level await. Under the old implementation, later imports could appear to complete before the first evaluation had finished, producing attempts to access exports before initialization. The rewritten loader produced the expected order and passed the module-related tests WebKit used during development, including tests from Test262 and Web Platform Tests. That is a stronger signal than simply saying that the syntax now parses correctly: the change addresses how dependent modules are evaluated.Β 

Check browser support before relying on it

For modern browsers, top-level await is broadly available, but Safari's history makes compatibility worth checking when supporting older installations. Current compatibility data lists Safari 27 as supporting top-level await, while earlier Safari versions have partial or missing support depending on the release. Chrome and Firefox have supported the feature for several major versions.

If your application must support older browsers, do not assume that converting a classic script into a module solves compatibility automatically. You may need a build step that transforms your source, or you can keep asynchronous initialization inside an explicitly asynchronous function. The latter remains a useful fallback because it does not depend on top-level module evaluation.

Use an async function when top-level await is the wrong fit

You can reproduce much of the same programming style with an asynchronous immediately invoked function expression when a module cannot use top-level await. This approach wraps the initialization code in an asynchronous function and starts it immediately.

(async () => {
  const response = await fetch("config.json");

  if (!response.ok) {
    throw new Error(`Configuration request failed: ${response.status}`);
  }

  const config = await response.json();

  startApplication(config);
})();

This is still useful for code that must run as a classic script or for projects whose build environment does not support top-level await cleanly. The trade-off is that values created inside the function are not automatically available as module exports, and other code must coordinate with the initialization explicitly. MDN documents this asynchronous function pattern as a way to mimic top-level await where the syntax itself is unavailable.Β 

Watch for the dependency chain you create

The biggest practical mistake is placing a slow or optional operation in a module that sits near the root of the application's dependency graph. If many modules import that module, they may all end up waiting for the same asynchronous initialization to finish. A configuration file that takes a fraction of a second may be reasonable; a request to an optional analytics service or secondary content endpoint probably does not belong on the critical path.

A useful rule is to ask whether the importing module can correctly evaluate without the awaited result. If the answer is no, top-level await can make that dependency explicit and easy to follow. If the answer is yes, consider loading the resource later through a function or dynamic import instead. Safari's repaired module loader makes the semantics more reliable, but it does not remove the architectural cost of making modules depend on asynchronous initialization.

How to test your module before shipping

Start by opening the application in a browser that supports top-level await and verify that the module loads without syntax errors. Then test a slow response, a failed request, and a module imported by several other modules. The failed request case is particularly important because an exception from a top-level await affects evaluation of the module and can therefore affect modules that depend on it. Testing only the successful network path can hide this dependency behavior.

Finally, test the application in the browser versions you actually support rather than relying only on your development browser. Safari 27 is the important recent change here, but the underlying feature has existed across other modern browsers for years. Once the module graph behaves correctly with both successful and failed asynchronous initialization, top-level await becomes a useful tool for expressing real startup dependencies rather than just a shorter way to write an asynchronous function.Β 

H

Written by

Hassan Raza

I enjoy building for the web and keeping up with the technologies that make modern websites and web applications possible. My interests include JavaScript, APIs, browser technologies, frontend development, and modern web platforms. I like experimenting with new techniques and turning what I learn into practical tutorials.

13 posts published

All posts by this author

0 Comments

No comments yet. Be the first to share your thoughts.

Join the conversation

Log in or create a free account to leave a comment. You can edit or delete your own comments any time.