1. What does asynchronous JavaScript mean?
Synchronous code runs one statement at a time. Each statement finishes before the next one begins. This is simple to follow, but it becomes a problem when a task takes an unknown amount of time.
Imagine a page requesting a large image from a server. The network might respond in a few milliseconds—or it might take several seconds. If JavaScript blocked while waiting, the page could not respond to clicks, update animations, or even paint new content. Asynchronous APIs let the runtime begin slow work and notify your JavaScript when the result is ready.
console.log("Start");
setTimeout(() => {
console.log("Timer callback");
}, 0);
console.log("Continue running");
// Start
// Continue running
// Timer callback
The timer is asynchronous: JavaScript schedules its callback and continues with the next statement. A zero millisecond timer means “run this when the current work and queued tasks allow,” not “run immediately.”
2. The event loop, in plain language
JavaScript code runs on a call stack. Browser and server runtimes also provide APIs for timers, network requests, and other operations. When an operation is ready to notify JavaScript, its callback is scheduled for later execution. The event loop coordinates when queued JavaScript can run.
Promise handlers use the microtask queue; events such as timers and many browser callbacks use task queues (often casually called the macrotask queue). After the current synchronous code finishes, the runtime processes pending microtasks before moving on to another task.
console.log("A");
setTimeout(() => console.log("B: timer"), 0);
Promise.resolve().then(() => console.log("C: promise"));
console.log("D");
// A
// D
// C: promise
// B: timer
This simplified model is useful for understanding ordering. The exact scheduling details vary by runtime and host API, but the key point is stable: asynchronous callbacks do not interrupt the JavaScript currently running, and Promise handlers run as microtasks.
3. Callbacks: the original everyday pattern
A callback is a function given to another function so it can be called later. Timers and browser events are familiar examples:
button.addEventListener("click", () => {
console.log("The button was clicked");
});
setTimeout(() => {
console.log("A little time has passed");
}, 1000);
Older APIs often use an error-first callback: the first argument reports an error, and the next argument contains the result.
readFile("settings.json", (error, contents) => {
if (error) {
showError(error);
return;
}
useSettings(contents);
});
Callbacks are still useful for events that may happen many times, such as a click or a stream of data. For an operation that completes once and produces one result, deeply nested callbacks can make the sequence hard to read and errors difficult to route. Promises provide a standard way to represent and compose those one-time results.
4. XMLHttpRequest: the classic browser request API
XMLHttpRequest (XHR) is an older browser
API for making HTTP requests. Despite its name, it can
request JSON, text, images, and other response types;
it is not limited to XML. XHR is event-based and
exposes request state and progress.
function getJson(url, onSuccess, onError) {
const request = new XMLHttpRequest();
request.open("GET", url);
request.responseType = "json";
request.addEventListener("load", () => {
if (request.status >= 200 && request.status < 300) {
onSuccess(request.response);
} else {
onError(new Error(`HTTP ${request.status}`));
}
});
request.addEventListener("error", () => {
onError(new Error("Network request failed"));
});
request.send();
}
getJson(
"/api/products",
(products) => renderProducts(products),
(error) => showError(error),
);
open() configures the method and URL;
listeners handle events; send() starts
the request. A load event means the
response arrived, not necessarily that the HTTP status
was successful, so code must inspect
status. The error event
covers network-level failures.
XHR can still be appropriate when you need features
such as upload progress events. For many ordinary
requests, fetch() provides a smaller,
Promise-based interface.
5. Promises and the modern fetch API
A Promise represents a result that will be available
later. It can be pending, fulfilled with a value, or
rejected with a reason. You can handle it with
.then() and .catch(), or
write Promise-based functions with
async and await.
The browser's fetch() function returns a
Promise for a Response. The body is read
separately, for example with
response.json(), which also returns a
Promise.
fetch("/api/products")
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then((products) => renderProducts(products))
.catch((error) => showError(error));
To send JSON, serialize the body and include the content type:
async function createProduct(product) {
const response = await fetch("/api/products", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(product),
});
if (!response.ok) {
throw new Error(`Could not create product: HTTP ${response.status}`);
}
return response.json();
}
Fetch also follows browser security rules such as same-origin policy and CORS. If a cross-origin server does not allow your origin, browser JavaScript cannot read its response just because the request URL is reachable.
For a deeper look at Promise states and composition, see JavaScript Promises, Explained.
6. Errors: when try/catch works—and when it doesn't
A try/catch catches a synchronous
exception thrown while execution remains inside its
try block:
try {
JSON.parse("{ invalid json }");
} catch (error) {
console.error("Could not parse JSON:", error.message);
}
But try/catch does not automatically catch
errors thrown later by an asynchronous callback:
try {
setTimeout(() => {
throw new Error("This happens later");
}, 0);
} catch (error) {
// This does not catch the timer callback's error.
}
The timer callback runs after the try block
has finished. For Promise-based work, use
.catch() or await inside
try/catch. A rejection becomes a thrown
value at the corresponding await.
async function loadProducts() {
try {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const products = await response.json();
renderProducts(products);
} catch (error) {
console.error("Product loading failed:", error);
showError("Products could not be loaded. Please try again.");
}
}
Catch errors where you can respond meaningfully: display a useful message, retry, or recover with a fallback. If a function cannot handle an error, let its returned Promise reject so a caller can decide what to do. Avoid catching and silently ignoring failures.
7. Async and await
Marking a function async makes it return a
Promise. Returning a value fulfills that Promise;
throwing an error rejects it. Inside the function,
await waits for a Promise to settle and
gives you its fulfillment value. If it rejects,
await throws the rejection reason.
async function getProduct(id) {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
throw new Error(`Product request failed: HTTP ${response.status}`);
}
return response.json();
}
getProduct("p-42")
.then((product) => renderProduct(product))
.catch((error) => showError(error));
await makes the current async function
pause at that point; it does not block the JavaScript
thread. Other events and work can continue while the
Promise is pending.
At the top level of JavaScript modules, top-level
await is also supported. In scripts or
environments where it is not available, put awaited
work inside an async function.
8. Practical asynchronous patterns
Run independent work concurrently
If requests do not depend on each other, start them
together with Promise.all(). It fulfills
with results in the same order as the input array, or
rejects if an input rejects.
async function loadDashboard() {
const [profile, activity] = await Promise.all([
fetchJson("/api/profile"),
fetchJson("/api/activity"),
]);
renderDashboard(profile, activity);
}
Awaiting the first request before starting the second would make them run sequentially. That is necessary only when the second operation depends on the first.
Keep independent failures independent
Use Promise.allSettled() when each task
should report its outcome, even if another one fails.
This is useful for optional widgets, bulk uploads, or
best-effort background tasks.
const outcomes = await Promise.allSettled([
fetchJson("/api/recommendations"),
fetchJson("/api/recent-items"),
]);
for (const outcome of outcomes) {
if (outcome.status === "fulfilled") {
renderWidget(outcome.value);
} else {
console.warn("Optional widget failed:", outcome.reason);
}
}
Cancel a request with AbortController
Promises do not have a built-in universal cancellation
method. Many browser APIs accept an
AbortSignal; fetch is one
example. Aborting causes the fetch Promise to reject,
so handle that case as part of normal control flow.
async function search(query) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Search failed: HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
In a real search interface, you can keep a controller
for the current request and abort it when a newer query
replaces it or the component is removed. Treat
AbortError as cancellation rather than
showing it as an unexpected server failure.
Use a timeout without leaking a timer
A timeout can race an operation, but racing does not by itself cancel the operation. Clear the timer when the request finishes, and use an abort signal if the underlying API supports cancellation.
async function fetchWithTimeout(url, milliseconds) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), milliseconds);
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
9. Common pitfalls
Forgetting to await or return asynchronous work
// The function starts, but this code does not wait for it.
async function saveAndNotify() {
saveRecord();
showSuccess();
}
// Wait for the save to finish before showing success.
async function saveAndNotifyCorrectly() {
await saveRecord();
showSuccess();
}
If the caller must also wait, return the asynchronous result. Async functions automatically return Promises, so callers can await them or attach a rejection handler.
Accidentally making independent work sequential
Multiple sequential await statements are
correct when each step needs the prior result. For
independent operations, start them together and await
Promise.all().
Assuming all failures are network errors
A failed request might mean a network problem, an aborted request, a non-success HTTP status, invalid JSON, or an application-level error in a valid response. Check and report each case appropriately.
Blocking the thread with CPU-heavy work
Wrapping an expensive loop in a Promise does not make the loop non-blocking. The loop still runs synchronously. Break up suitable work, use a Web Worker, or choose an API designed for background processing.
10. A practical mental model
- Synchronous JavaScript runs now; asynchronous APIs arrange for work to report back later.
- Callbacks are functions for later notification; Promises standardize one-time future results.
-
fetch()returns a Promise, and HTTP error statuses must be checked explicitly. -
async/awaitis a readable syntax for working with Promises. -
Use
try/catcharound awaited work; callbacks that run later need their own error handling. - Run independent operations concurrently, and cancel obsolete work when the API allows it.
When reading asynchronous code, follow three things:
what starts the operation, where its result goes, and
where its failure is handled. That habit works whether
the code uses XHR callbacks, Promise chains, or
async/await.