Why JavaScript is single-threaded and asynchronous
JavaScript was designed in ten days in 1995 to add interactivity to web pages. Brendan Eich's brief was to make a language that looked like Java, worked in the browser, and could manipulate the DOM safely. The decision that shaped everything that followed was to make the language single-threaded. The browser already had a rendering thread, a network thread, and a UI thread; adding a parallel JavaScript thread would have meant cross-thread DOM access, which is a category of bug that has killed larger projects. One thread, one heap, one event loop.
That decision means that any long-running operation in JavaScript — fetching a resource, reading a file, waiting for a timer — has to be expressed asynchronously. If the main thread blocks on a network request, the entire page freezes: no scrolling, no animation, no input. The browser labels such pages as unresponsive and offers to kill them. So the language evolved a series of mechanisms to express do this, then do that when the first thing finishes, without blocking — first callbacks, then promises, then async/await. Each was an improvement on the last, but all three coexist in modern code, and a working JavaScript developer needs to understand all of them.
Understanding the asynchronous model is not optional for senior work. Every interaction with the network, the filesystem, a database, or a worker is asynchronous. Every animation, every debounce, every retry loop, every race condition you have ever chased — they are all consequences of this model. The good news is that the model is small, and once it clicks, async code stops being mysterious.
Callbacks and the pyramid of doom
The original asynchronous primitive in JavaScript was the callback. You passed a function to an asynchronous operation, and the operation called your function when it finished. The Node.js standard library was built on this pattern: `fs.readFile('path', (err, data) => { ... })`. The browser's `addEventListener`, `setTimeout`, and `XMLHttpRequest` all worked the same way.
Callbacks work for a single asynchronous step. They fall apart when you need to chain steps. Read a config file, parse it, fetch a URL listed in the config, write the result to disk. In callback form, the code drifts rightward with every step, indented into a shape nicknamed the Pyramid of Doom. Each callback has its own `err` parameter, and forgetting to check any of them means an error silently propagates. The pattern also makes control flow nearly impossible: try implementing do these three things in parallel, then do this when all three finish with raw callbacks. You end up writing a manual counter, and you will get it wrong.
There is a more subtle problem with callbacks: inversion of control. When you pass a callback into a third-party function, you are handing that function the right to call your code. It can call it once, twice, never, or with unexpected arguments. You have no way to enforce that the callback is called the way you expect. Promises solve this by giving you an object that represents the future result, rather than handing control of your code to someone else.
You will still see callbacks in event handlers (`addEventListener`) and in some Node APIs (`stream.on('data', ...)`), and that is fine — callbacks are the right tool when you want to be notified of something that may happen repeatedly. They are the wrong tool when you want the result of an operation that happens once. For once-only operations, use promises.
Promises: states, chaining, and composition
A Promise is an object that represents a value which may not be available yet. It has three states: pending, fulfilled, and rejected. Once a promise is fulfilled or rejected, it stays in that state forever — promises are immutable after settlement. This single property makes them dramatically easier to reason about than callbacks.
The `then` method is how you chain. `then` takes two arguments, an onFulfilled handler and an onRejected handler, and returns a new promise. Crucially, if your handler returns a value, the new promise is fulfilled with that value; if your handler returns another promise, the new promise adopts the state of the returned promise. This means chaining just works: a series of `then` calls reads top to bottom, and a single `catch` at the end of the chain handles any error from any step.
Where promises really shine is composition. Four static methods on `Promise` cover the common cases:
- `Promise.all` takes an array of promises and fulfills with an array of results when all of them fulfill. If any one rejects, the whole thing rejects immediately.
- `Promise.allSettled` takes an array of promises and fulfills with an array of result objects (`{status, value}` or `{status, reason}`) when all of them settle. Nothing short-circuits; you see every outcome.
- `Promise.race` takes an array of promises and settles (fulfills or rejects) with the first one to settle. Useful for implementing timeouts.
- `Promise.any` takes an array of promises and fulfills with the first one to fulfill. It only rejects if all of them reject.
The naming is mnemonic once you internalize the difference: `all` rejects fast, `allSettled` waits for everything, `race` settles fast, `any` fulfills fast.
A practical pattern that uses these: implementing a timeout. You race your operation against a promise that rejects after N milliseconds:
``` function withTimeout(promise, ms) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)); return Promise.race([promise, timeout]); } ```
That four-line helper has saved more production outages than any framework feature. Use it whenever you call a network resource that might hang.
Async/await: syntax sugar that changed everything
Async/await, added to JavaScript in ES2017, is syntactic sugar on top of promises — but the kind of sugar that changes how you write code. You mark a function `async`, and inside it you can `await` any promise. The `await` keyword pauses the function until the promise settles, then returns the fulfilled value or throws the rejection reason. The Pyramid of Doom collapses into code that looks synchronous.
Reads like synchronous code, but the function yields control to the event loop at every `await`, so the browser stays responsive. Error handling uses the synchronous `try`/`catch` you already know, which means a single try/catch around a sequence of awaits handles any error from any step — exactly what promises needed a chain of `then` and `catch` to achieve.
What actually happens when you `await` is that the async function returns a promise immediately, and the code after the `await` is scheduled as a `then` callback on the awaited promise. The execution context (local variables, the try/catch frame) is preserved in a closure, so when the awaited promise settles, the function resumes with everything intact. There is no thread blocking; there is only coroutine-style suspension. Understanding this is what separates developers who use async/await from developers who debug it.
One subtlety: an async function always returns a promise. If you `return` a plain value, the runtime wraps it in a resolved promise. If you `throw`, the runtime wraps the error in a rejected promise. Callers must `await` your async function (or use `.then`) to get the value, even if the function returns synchronously in practice. There is no way to make an async function synchronously return its underlying value.
Parallel, sequential, and the Promise combinators
A common mistake is to `await` everything in sequence when the operations are independent. If you write:
``` async function loadAll() { const a = await fetch('/api/a'); const b = await fetch('/api/b'); const c = await fetch('/api/c'); } ```
you wait for `a`, then start `b`, then start `c`. If each request takes 200 milliseconds, the total is 600 milliseconds. If the requests are independent, you should run them in parallel:
``` async function loadAll() { const [a, b, c] = await Promise.all([ fetch('/api/a'), fetch('/api/b'), fetch('/api/c'), ]); } ```
Now the total is 200 milliseconds — the time of the slowest request. `Promise.all` starts all three fetches immediately, and `await` waits for all of them.
The mistake is subtle because the code looks the same and produces the same result. The performance difference can be a factor of three or more. Get in the habit of asking, every time you write `await` in a loop or in a sequence: do these calls depend on each other? If not, batch them with `Promise.all`.
A related pattern: limited concurrency. If you have a thousand URLs to fetch, `Promise.all` will fire all thousand at once, which will overwhelm your connection limit, get you rate-limited, or run you out of memory. You need a pool that runs, say, eight requests at a time. There is no built-in primitive for this in JavaScript. Reach for a small library (`p-limit` is the standard one), or write the loop yourself with a counter and a queue.
Another pattern worth knowing: `for await...of`, which lets you iterate over an async iterable. This is the right tool when you have a stream of items arriving over time — server-sent events, paginated API responses, or a database cursor — and you want to process them one at a time without buffering the whole sequence in memory.
Common pitfalls: forgotten await, unhandled rejections
Async/await introduces new ways to write bugs that did not exist with raw callbacks. The most common is forgetting to `await` an async call. A function that calls `sendEmail(user.email)` without await returns immediately, before the email is sent. If `sendEmail` throws, the error becomes an unhandled promise rejection, which in modern Node crashes the process. The fix is to `await` every async call, or to explicitly not await it with a comment explaining why.
A second pitfall: `return await` versus `return`. Inside an async function, `return await promise` and `return promise` produce nearly identical results, but with one important difference. `return await` wraps the promise in a try/catch that catches rejections in the local function. `return promise` does not — the rejection propagates directly to the caller. If your function has a `try`/`catch` around the awaited call, you want `return await`; otherwise `return promise` is slightly faster because it skips one microtask. ESLint has a `no-return-await` rule that enforces the latter, but it is wrong for code with local error handling.
A third pitfall: awaiting inside a loop. `for (const id of ids) { await fetch(`/api/${id}`); }` runs each request sequentially, which is rarely what you want. Either batch with `Promise.all(ids.map(id => fetch(`/api/${id}`)))` for full parallelism, or use a concurrency-limited pool if the list is long. Sequential awaits in a loop are the slowest possible pattern and the easiest to write by accident.
Unhandled rejections are the async equivalent of uncaught exceptions, and they are more dangerous because they can sit silently. In a browser, an unhandled rejection logs a warning to the console but does not break the page. In Node, since version 15, an unhandled rejection terminates the process. Add a global handler at the top of your application — `process.on('unhandledRejection', ...)` in Node, `window.addEventListener('unhandledrejection', ...)` in the browser — that logs the error to your monitoring service. Otherwise you will not know these failures exist.
The event loop, microtasks, and macrotasks
The last piece is the event loop itself. JavaScript runs tasks on a single thread, but the runtime (browser or Node) maintains several queues. The two that matter for async code are the macrotask queue and the microtask queue.
Macrotasks include `setTimeout` callbacks, `setInterval` callbacks, I/O callbacks, UI events, and `messageChannel` messages. Microtasks include promise callbacks (`then`, `catch`, `finally`), `queueMicrotask` callbacks, and (in Node) `process.nextTick`.
The rule that governs everything: when the current macrotask finishes, the runtime drains the entire microtask queue before running the next macrotask. This means a chain of promise callbacks runs to completion before any `setTimeout` fires, even one with a zero delay. Consider:
``` console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D'); ```
The output is `A D C B`. `A` and `D` are synchronous. `C` is a microtask, so it runs after the current script but before the next macrotask. `B` is a macrotask, so it runs last. This ordering is consistent across browsers and Node, and it is the source of more interview questions and real-world bugs than any other part of the language.
Understanding the microtask priority explains why `await` does not yield to the event loop in the way developers often assume. `await Promise.resolve()` schedules the continuation as a microtask, which runs immediately after the current synchronous code finishes — before any pending `setTimeout`. If you actually want to yield to the browser (to let it paint, or to break up a long task), you need `await new Promise(r => setTimeout(r, 0))` or, in modern browsers, `await scheduler.yield()`. The difference matters for performance: a function that awaits a hundred promises in a loop runs entirely within one macrotask and never yields, which can block the main thread for hundreds of milliseconds.
This is the underlying cause of poor Interaction-to-Next-Paint (INP) scores on single-page applications. A click handler that kicks off a long chain of awaited operations may not yield to the browser until the whole chain finishes, leaving the user staring at an unresponsive page. Breaking the chain into chunks with explicit yields — or moving the work into a Web Worker — is the fix. Modern Chrome ships a `scheduler.yield()` API precisely for this case, and it is worth learning.
Asynchronous JavaScript is not magic, but it has a learning curve. The payoff is worth it: once you understand promises, async/await, and the event loop, you can write code that handles network calls, file I/O, and user input without ever freezing the page. That is the difference between a script that runs and a script that scales.