AskHandle

AskHandle Blog

How to Prevent Race Conditions During Asynchronous Initialization

June 15, 2026Katherine Holland3 min read

How to Prevent Race Conditions During Asynchronous Initialization

Race conditions during asynchronous initialization happen when several startup tasks run at the same time, but their results arrive in an order your code did not safely plan for. On page load, a component may fetch user data, load settings, open a WebSocket, restore cached state, and attach event listeners. If the user leaves the page and returns through the browser back/forward cache, or through normal back and forward actions, old async callbacks may still finish later. When those callbacks try to update a destroyed, replaced, or stale component, the result can be confusing errors, memory leaks, duplicate connections, or corrupted UI state.

What Is a Race Condition in Async Initialization?

A race condition occurs when code depends on timing that is not guaranteed. In synchronous code, one step finishes before the next begins. In asynchronous code, tasks may complete in any order.

For example, a page might start three tasks:

js
1fetchUser();
2loadSettings();
3startWebSocket();

The developer may expect user data to arrive first, settings second, and the socket last. Real browsers do not work that way. Network speed, cache state, server response time, CPU load, and page lifecycle events can all change the order.

The problem becomes more serious when the user leaves the page before those tasks finish. The component that started the work may no longer be valid. Still, the old promise callback, timeout, subscription, or socket event can fire afterward.

That means this line may run too late:

js
1setState({ user });

If the component has already been destroyed, the update may fail. If a new copy of the component exists, the old callback may update the wrong version.

Why Page Load Is a Common Trouble Spot

Page load is often full of initialization work. It is common to do all of these at once:

js
1const userPromise = fetch("/api/user");
2const settingsPromise = fetch("/api/settings");
3const socket = new WebSocket("/events");

This feels efficient because everything starts early. The risk is that each task has its own lifecycle. Fetch requests may complete after the UI is gone. WebSockets may remain open. Event listeners may keep references to old objects. Timers may continue running.

Browsers add another twist with the back/forward cache, often called bfcache. When a page is stored in bfcache, it is not fully destroyed in the usual way. It may be paused and resumed later. Some code may expect a full reload, while the browser restores the previous page from memory. This can produce duplicate initialization if your code starts everything again without checking what already exists.

The Stale Component Problem

A stale component is an old instance that should no longer affect the screen. It may have been removed, replaced, or paused. The async work it started can still hold a reference to it.

A typical bug looks like this:

js
1function initProfilePage() {
2  fetch("/api/user")
3    .then(res => res.json())
4    .then(user => {
5      profileComponent.render(user);
6    });
7}

This code assumes profileComponent is still valid when the fetch completes. That assumption is unsafe.

A safer approach checks whether the component is still active:

js
1let active = true;
2
3function initProfilePage() {
4  fetch("/api/user")
5    .then(res => res.json())
6    .then(user => {
7      if (!active) return;
8      profileComponent.render(user);
9    });
10}
11
12function destroyProfilePage() {
13  active = false;
14}

This is simple, but it only solves part of the issue. The request still runs. The callback still exists. For better cleanup, cancel the work when possible.

Cancel Async Work with AbortController

For fetch, use AbortController. It lets you cancel a request when the page or component is no longer active.

js
1let controller;
2
3function initProfilePage() {
4  controller = new AbortController();
5
6  fetch("/api/user", { signal: controller.signal })
7    .then(res => res.json())
8    .then(user => {
9      renderUser(user);
10    })
11    .catch(error => {
12      if (error.name === "AbortError") return;
13      showError(error);
14    });
15}
16
17function destroyProfilePage() {
18  controller.abort();
19}

This prevents the request from continuing after it is no longer needed. It also keeps your error handling clean because aborted requests are treated separately from real failures.

Use Initialization Tokens

Cancellation is useful, but not every async API supports it. For those cases, use a version token.

Each time the page initializes, create a new token. Every callback checks whether its token is still current before changing state.

js
1let initVersion = 0;
2
3function initPage() {
4  const version = ++initVersion;
5
6  loadSettings().then(settings => {
7    if (version !== initVersion) return;
8    applySettings(settings);
9  });
10
11  fetchUser().then(user => {
12    if (version !== initVersion) return;
13    renderUser(user);
14  });
15}
16
17function destroyPage() {
18  initVersion++;
19}

This protects against old callbacks that finish after a newer page instance has started. It is especially helpful when users move quickly between pages.

Clean Up WebSockets and Subscriptions

WebSockets are a common source of leaks because they keep running until closed. If a component starts a socket, that component should also close it.

js
1let socket;
2
3function initSocket() {
4  socket = new WebSocket("/events");
5
6  socket.onmessage = event => {
7    updateFromEvent(JSON.parse(event.data));
8  };
9}
10
11function destroySocket() {
12  if (!socket) return;
13
14  socket.onmessage = null;
15  socket.onerror = null;
16  socket.onclose = null;
17  socket.close();
18  socket = null;
19}

The same rule applies to event listeners, intervals, observers, and custom subscriptions. If you start it during initialization, stop it during cleanup.

Handle Browser Back and Forward Events

For pages affected by bfcache, listen to lifecycle events such as pageshow and pagehide.

js
1window.addEventListener("pageshow", event => {
2  initPage({ restored: event.persisted });
3});
4
5window.addEventListener("pagehide", () => {
6  cleanupPage();
7});

When event.persisted is true, the page was restored from bfcache. In that case, you may need to refresh stale data, reconnect a socket, or skip duplicate setup.

The key is to make initialization repeatable. Calling initPage() twice should not create two sockets, two timers, or two sets of listeners.

Make Initialization Idempotent

Idempotent initialization means running setup more than once does not break the app. This is one of the best defenses against async race bugs.

For example:

js
1function initPage() {
2  cleanupPage();
3
4  startUserRequest();
5  startSettingsRequest();
6  startSocket();
7}

Cleaning up before starting again may look heavy-handed, but it creates a clear rule: there is only one active page instance at a time.

Another option is to guard setup:

js
1let initialized = false;
2
3function initPage() {
4  if (initialized) return;
5  initialized = true;
6
7  startSocket();
8  loadData();
9}
10
11function cleanupPage() {
12  initialized = false;
13  stopSocket();
14}

Use whichever pattern matches your app. The important part is that startup and teardown are paired.

Practical Checklist

Use this checklist when reviewing async initialization code:

  • Cancel fetch requests with AbortController.
  • Close WebSockets when the page or component is removed.
  • Remove event listeners during cleanup.
  • Clear intervals and timeouts.
  • Ignore stale callbacks with version tokens.
  • Treat bfcache restores as a special page lifecycle case.
  • Make initialization safe to run more than once.
  • Keep cleanup logic close to setup logic.
  • Avoid sharing mutable state across old and new component instances.
  • Test quick back, forward, refresh, and tab switching behavior.

Race conditions with asynchronous initialization are not just timing bugs. They are lifecycle bugs. The browser, network, and user can all change what runs first and what finishes last. A reliable page treats every async task as something that may outlive the component that started it. Cancel work when possible, ignore stale results when cancellation is not available, and always pair setup with cleanup. When your initialization code respects the page lifecycle, your app becomes more stable, easier to debug, and far less likely to leak memory or update the wrong UI.