Asynchronous JavaScript for the Browser — Complete Picture

The Problem: Everything Stops Working!

Why Asynchronous Programming?

Asynchronous programming is essential for handling blocking code — code that takes too long, like:

  • Heavy computations

  • I/O operations

  • Timed actions

  • Event handling (like responding to user clicks)

These tasks often take a definite time to complete, and we need to handle them in a non-blocking way. Without this, the main thread would be stuck waiting for these tasks to finish, leading to a frozen or unresponsive website. Asynchronous programming allows us to run these tasks without forcing the browser to wait.

Callbacks: The Basics of Async

Asynchronous programming is enabled by a Functional Programming concept, callbacks — functions that get called when certain conditions are met. While we’ve seen callbacks with .map(), here we’re talking about Deferred callbacks: functions that get executed later, under specific circumstances.

There are ways to achieve Async, but first we need to understand the system.

The Event Loop: How It Works on the Browser

The event loop, which is a task scheduler, decides what happens on the main JavaScript thread. It works in phases, which come in every cycle of the loop and it keeps looping. The phases are :

  1. 1.

    Rendering (in the browser)

  2. 2.

    JS execution

  3. 3.

    Macro and Micro Task execution

Rendering and garbage collection are beyond our current scope.
And moving on I assume JS execution is a known thing — Heap, Call stack, Frames, etc.

Tasks and Queues

Tasks are jobs related to timers, I/O and events.
You tried to click something, you queued a task.
You selected some text, task.
Trying to fetch some data ? Task.

They can be taken up by WebAPIs if possible.
Processed Tasks pr their associated callbacks are then added to a First-in-First-Out
queue called the Macrotask queue or Callback queue.

Web API

A Web API is like a function provided by the browser that handles tasks such as I/O, timers, or fetching data in separate threads. 
An
API (Application Programming Interface) is a set of functions or tools that allows one piece of software to interact with another.
In this case, the browser gives us APIs to handle tasks on separate threads (like timers or fetch requests), freeing the main thread, outside the JavaScript engine, enabling asynchronous behavior without blocking the main thread.

When the Web API completes its task, it adds it to the task queue. The event loop, when it reaches this phase picks up these tasks one by one and places them on the call stack (ONLY if the stack is empty). Only one task runs at a time, per cycle of the loop, meaning the environment might change before the task gets executed.

Why add it to queue?

When working with extra threads, the need arises for synchronizing with main thread when the work is settled (success/ err).
This responsibility lies with the event loop and thus the flow is the way it is.

about Rendering —

Rendering never happens while the engine executes a task. It doesn’t matter if the task takes a long time. Changes to the DOM are painted only after the task is complete.
If a task takes too long, the browser can’t do other tasks. So after some time, it raises an alert like “Page Unresponsive”, suggesting killing the task with the whole page. That happens when there are a lot of complex calculations or a programming error leading to an infinite loop.

Does this solve our problem ?

Now that the task is on the call stack, it is like any other function.
Although the rest of our JS ran to completion, let’s say it is an infinite loop, what happens now ?
Screen freezes as the thread is blocked.

Special mention — Web Workers :

A web api that needs more attention in this context is the Worker.
Web Workers can take up heavy computations on your behalf, work it on another thread and give you the results back.
But it is a big overhead, managing and integrating the results back so it is not very popularly used.

So is problem solved ?

Callbacks are useful, but they have problems:

  1. 1.

    Inversion of Control: The API decides when to call your callback, which can lead to unexpected issues.

  2. 2.

    Callback Hell: Nesting callbacks for sequential operations (e.g., do X, then Y, then Z) creates unreadable code — known as the Pyramid of Doom. It is an Anti-Pattern.

  3. 3.

    Managing success/failure/errors properly, specially in nested callbacks.

Bonus — Anti Patterns :

Common practice or coding pattern that might seem like a good solution initially but leads to poor outcomes in terms of code quality, performance, maintainability, Unintended Side Effects or scalability. While not always immediately harmful, anti-patterns often lead to issues over time, especially as a project grows more complex.
- — — — — — — — — — — — — — -

Promises :

To solve these problems, we use Promises. Just like it’s name, a Promise is an object that represents a value that may be available now, or in the future.
They return special objects, which has the fields for data and the state of the promise. Promises allow you to handle async operations in a cleaner way, attaching callbacks using 
.then() for chaining and .catch() for error handling.

These handlers also return their very own promises, so it makes it incredibly nice to chain. As the next handler gets called only after the previous one settles.

Promises vs Callbacks

By promises, functions can be deferred to be called at a later time, when the call stack is empty. This mimics actual asynchronous, parallel work, but is still an important tool to make non-blocking experiences.

Promises help solve the issues with callbacks:

  • Better readability: Promises flatten callback nesting.

  • Error handling: Errors are handled in one place with .catch().

  • Sequencing: You can chain async operations easily with .then().

IMPORTANT : This does not mean that everywhere promises were being used should now be replaced with Promises.
We still want an un-deferred Array.map ; We would still like a plain old callback for a setTimeOut.
What we don't want is a clumsy handle on a API fetch, or dependent API queries !

Microtasks

In addition to the Macrotask queue, there is a Microtask queue, which handles things like promises
How it differs from Tasks is that :
Microtask queue run to completion, meaning the event loop handles all pending microtasks before moving on.

This is important so that the calling environment does not change, so local context can be manipulated.

What runs first ? micro or macro

That is a wrong question. If you are asking this, you know the pieces but you don’t know their responsibility.

  1. 1.

    The main JavaScript code execution is your ‘first task’ or the ‘main task’.

  2. 2.

    It queues up some Macro task, lets say a setTimeOut and a fetch .

  3. 3.

    It also queues up some direct Promises, let’s say to do some animation.

The promise is a direct delegation from the ‘main task’, like a follow up command. It would have been a part of the execution, except for the conscious decision to do it asynchronously.
Whereas
The time out and the fetch are follow up tasks.
The timeout just queues the callback, ready to be called, but the fetch queues up it’s own promises in the micro tasks.
These micro tasks are again, as you can now very well understand, related to the fetch and are needed to be taken up before taking any other Macro task.

So, the macro and it’s associated micro tasks make up the whole unit, then rendering can be taken up to reflect the changes that were done.

This is also why macro tasks execute one at a time while micro tasks are all processed at once

But Inversion of Control Remains.

Promises are Inversion of Control also.

Why Promise handlers always return Promises is by design.
 The API implementor does not control when the attached callback gets called. Instead, the job of maintaining the callback queue and deciding when to call the callbacks is delegated to the promise implementation, the delegation is much like Callbacks.

However, promises provide stronger guarantees as mentioned above, hence are a good choice.

Zalgo Anti-Pattern

The fact that the Promise handlers always run asynchronously, avoids the Zalgo antipattern and keeps code consistent and manageable.

The Zalgo antipattern occurs when a function’s callback is sometimes executed synchronously and sometimes asynchronously, making the code non-deterministic. This unpredictability leads to hard-to-find bugs because you can’t be sure when the callback will run.

If the callback runs synchronously, it might immediately affect local variables, while async execution delays it, causing inconsistent state management. Promises always enforce async behavior with .then(), avoiding this issue by ensuring callbacks run predictably after the current event loop.

Tasks run to completion (it is not a ‘Given’) -

Each item is processed completely before any other message is processed.

This offers some nice properties when reasoning about your program, including the fact that whenever a function runs, it cannot be preempted and will run entirely before any other code runs (and can modify data the function manipulates). This differs from C, for instance, where if a function runs in a thread, it may be stopped at any point by the runtime system to run some other code in another thread.

A downside of this model is that if a message takes too long to complete, the web application is unable to process user interactions like click or scroll. The browser mitigates this with the “a script is taking too long to run” dialog. A good practice to follow is to make message processing short and if possible cut down one message into several messages.

This is how the non-blocking — asynchronous behavior is achieved for JavaScript.

Image for SEO — not OC, copied from google

Async — Await

Often called a syntax sugar , but it is NOT the case.
It does solve the syntax problem with Promises and chaining, making the code more
Readable, in the sense that it looks like synchronous code that we all are familiar with, while solving the problem we intended to solve with Promises.

But the underlying behavior is different.
The fundamental difference between
await and vanilla promises is that await X() suspends execution of the current function, while promise.then(X) continues execution of the current function after adding the X call to the callback chain.

It does seem counter intuitive at first, that we are ready to ‘wait for a while’ for something to get done. But as stated, that’s just from the looks of it.

The added benefit comes from how Stack Traces are managed — the stack trace can be constructed just like it would be for a synchronous function. Thus enabling JavaScript engines to handle stack traces in a more performant and memory-efficient manner.

Why does async even exist if await does the work?

This is the question everyone skips past. async is not just a permission slip that lets you write await inside a function. It does one very specific thing on its own, with no await anywhere near it:

It wraps whatever the function returns in a Promise.

function normal() {  return 5;}normal(); // 5async function wrapped() {  return 5;}wrapped(); // Promise<5> — not 5

That’s it. That’s the whole job of the keyword. await is only legal inside a function marked async because the engine needs to know ahead of time: "this function's return value will be a Promise, so when you hit await, suspend and hook into the microtask queue instead of erroring out."

So the two keywords aren’t a package deal because they do the same thing — async changes what the function returns, await changes what happens inside the function while it runs. You can have one without the other, and both are useful alone.

The full lifecycle — what actually goes in the queue

Here’s the exact question people get wrong: does the whole async function get queued, or just the await, or what?

Neither, exactly. Walk through this:

async function foo() {  console.log("A");  await bar();  console.log("B");}console.log("start");foo();console.log("end");
  • foo() is called. It runs immediately, synchronously, just like any other function call. Nothing about calling an async function is deferred. "A" logs right now, on the current call stack.

  • Execution hits await bar(). Two things happen at once: bar() runs (it's a normal call), and foo suspends — it stops executing and hands control back to whoever called it. foo() itself returns a pending Promise to the caller immediately, without waiting for anything.

  • So console.log("end") runs next, before "B". Order so far: start, A, end.

  • When bar()'s promise settles, the rest of foo after the await — not the whole function, just that leftover chunk — gets scheduled as a microtask.

  • Once the current synchronous code finishes and the call stack is empty, the event loop drains the microtask queue. That’s when "B" finally logs.

Final order: start, A, end, B.

The thing to correct in people’s heads: the function isn’t “sent to a queue.” It runs like normal code up to the first await. Only the continuation after await — the part that has to wait for the result — gets queued, and it's queued as a microtask, using the exact same microtask queue that Promise .then() callbacks use. That's why your earlier "microtask vs macrotask" section is the actual prerequisite for understanding this — await doesn't introduce a new mechanism, it's sugar on top of the microtask queue you already explained.

One more piece worth a line: this also answers why await someValue works even when someValue isn't a Promise (await 5). The engine still wraps it (Promise.resolve(5)) and still defers the continuation to a microtask — it never just "continues immediately" even though there's nothing to actually wait for. People assume await on a plain value is a no-op. It isn't — you still give up your turn on the stack.

What happens if you forget one of the keywords

Two different mistakes, two different outcomes:

await without async:

function foo() {  const data = await fetch(url); // SyntaxError}

This isn’t a runtime bug, it’s a parse-time error. The engine won’t even run the file. (Exception: top-level await at the top level of an ES module is legal without wrapping it in async — that's a special case worth its own line so people don't get confused when they see it "work" in a module but not inside a regular function.)

async without await:

async function foo() {  console.log("just doing sync stuff");  return 5;}

This is legal and common. foo runs fully synchronously, top to bottom, same as a normal function — there's no suspension point because there's no await. But it still returns a Promise, because that's what async does regardless of whether you used await inside. This trips people up constantly: they call an async function, get a Promise back, and are confused why — nothing "async" happened inside it as far as they can tell. The answer is: the keyword on the function signature is what creates the Promise, not the presence of an await.

Edge Cases That Actually Bite You

Sequential awaits vs Promise.all — the accidental performance bug

// slow — each await blocks the next line from startingconst a = await getA();const b = await getB();// fast — both start at the same timeconst [a, b] = await Promise.all([getA(), getB()]);

If getA and getB don't depend on each other's result, writing them as two separate awaits isn't a style choice — it's a bug. The first version waits for getA to fully settle before getB even starts. The second kicks both off in the same tick and waits for whichever finishes last. On a page with a handful of independent API calls, this is the difference between load time being additive or being roughly the slowest single call.

'for loop’ with await vs .map() with an async callback

// runs one at a time, in orderfor (const url of urls) {  const res = await fetch(url);}// fires all requests immediately, "waits" for nothingurls.map(async (url) => {  const res = await fetch(url);});

The .map() version doesn't wait for anything, because .map() has no idea what async means — it just calls your callback once per item and throws away the returned Promises. All the requests fire off in the same tick. If you actually want them to run in parallel and wait for all of them, wrap it: await Promise.all(urls.map(async (url) => fetch(url))). If you want them one at a time in order, keep the for loop.

Unhandled promise rejections

async function risky() {  throw new Error("boom");}risky(); // nobody catches this

Nobody awaits or .catch()s risky()'s returned Promise here. The error doesn't vanish — it surfaces as an unhandled promise rejection, which in the browser fires on window as an unhandledrejection event (and in Node, can crash the process depending on version/config). It does not throw synchronously where you called risky() — by the time the rejection is detected, the calling code has already moved on. This is worth a line because it's the single most common source of "silent" failures in async code — the error happened, it's just not where you're looking.

try/catch around await — what it actually catches

async function foo() {  try {    const data = await fetch(url); // rejects → caught    JSON.parse("not json");         // sync throw → also caught  } catch (e) {    console.error(e);  }}

Both a rejected awaited Promise and a plain synchronous throw inside the same try block get caught by the same catch. That's actually one of the real wins of async/await over raw .then() chains — with promises you need .catch() for rejections and a separate try/catch for sync throws inside a .then() callback. With await, they unify under one try/catch, because the engine converts a sync throw inside an async function into a rejected Promise automatically.


If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.