JavaScript
async
debugging
asynchronous
code execution

Javascript async code only works when debugging

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

If asynchronous JavaScript only seems to work when you step through it in a debugger, you almost certainly have a timing bug. Breakpoints slow the event loop down and accidentally hide race conditions, missing await calls, or lifecycle mistakes that still exist when the code runs at full speed.

Why the Debugger Changes the Result

A debugger changes timing. Promises have more time to settle, DOM updates finish before dependent code runs, and async initialization may complete before the rest of the program touches shared state.

That means the debugger is not fixing the bug. It is acting like temporary synchronization.

A simple example:

javascript
1let config;
2
3async function init() {
4  const response = await fetch("/config.json");
5  config = await response.json();
6}
7
8init();
9console.log(config);

When this runs normally, config is often still undefined. When you single-step in the debugger, the pause may give init() enough time to finish first, which makes the bug appear to disappear.

Make the Dependency Explicit

The correct fix is to express the dependency in code:

javascript
1let config;
2
3async function init() {
4  const response = await fetch("/config.json");
5  config = await response.json();
6}
7
8async function boot() {
9  await init();
10  startApp(config);
11}
12
13function startApp(cfg) {
14  console.log("app started with", cfg);
15}
16
17boot().catch((err) => {
18  console.error("boot failed", err);
19});

Now the program says exactly what it means: the app must not start until initialization is complete.

Do Not “Fix” It With setTimeout

One of the most common bad fixes is to add an arbitrary delay:

javascript
setTimeout(() => {
  startApp(config);
}, 100);

This is not synchronization. It is just guessing how much time the async work might need.

It may appear to work:

  • on one machine
  • with one network speed
  • in one browser
  • under one CPU load

and then fail elsewhere.

If the real problem is ordering, the right tools are:

  • 'await'
  • returned promises
  • explicit initialization state
  • a shared in-flight promise

Not a sleep disguised as a fix.

Return Promises From Helper Functions

Another frequent source of “works only in the debugger” bugs is helper functions that start async work but do not return the promise.

Wrong:

javascript
1function loadUser() {
2  fetch("/user")
3    .then((r) => r.json())
4    .then((user) => {
5      console.log(user);
6    });
7}

Better:

javascript
1function loadUser() {
2  return fetch("/user").then((r) => r.json());
3}
4
5async function showUser() {
6  const user = await loadUser();
7  console.log(user);
8}

Returning the promise is what gives the caller a real way to wait for the work to finish.

Use a Shared Initialization Promise

If multiple parts of the program depend on the same startup task, a shared initialization promise often solves the race cleanly:

javascript
1let initPromise;
2
3function initializeOnce() {
4  if (!initPromise) {
5    initPromise = fetch("/config.json")
6      .then((response) => response.json())
7      .then((data) => {
8        window.appConfig = data;
9        return data;
10      });
11  }
12  return initPromise;
13}
14
15async function featureA() {
16  const cfg = await initializeOnce();
17  console.log("A using", cfg.version);
18}
19
20async function featureB() {
21  const cfg = await initializeOnce();
22  console.log("B using", cfg.version);
23}

This avoids multiple overlapping startup paths that race against each other.

Log Phase Transitions, Not Just Values

When debugging async problems, logs are most useful when they tell you what finished before what.

javascript
1async function boot() {
2  console.log("boot:start");
3  const cfg = await initializeOnce();
4  console.log("boot:config-loaded");
5  renderUi(cfg);
6  console.log("boot:ui-rendered");
7}

That timeline often reveals the bug faster than stepping every line manually.

Unhandled rejection logging can also expose failures that breakpoints accidentally mask:

javascript
window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled rejection:", event.reason);
});

Common Pitfalls

The biggest mistake is “fixing” the bug with setTimeout instead of correcting the missing dependency edge.

Another issue is starting async work inside a helper but forgetting to return the promise, so callers cannot coordinate with it.

People also often mutate shared global state from multiple async paths without one clear owner or initialization contract.

Finally, debugger success is not proof of correctness. If the program only works when slowed down, the real bug is still present.

Summary

  • Async JavaScript that only works while debugging usually has a timing or ordering bug.
  • Breakpoints hide races by slowing down surrounding work.
  • Use await, returned promises, or explicit lifecycle control instead of arbitrary delay hacks.
  • Shared initialization promises are a clean way to coordinate startup work.
  • Log phase transitions and unhandled rejections to make hidden async failures visible.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.