Asynchronous JavaScript
Why async matters
JavaScript is single-threaded — it runs one thing at a time. But web apps need to do things that take time: fetching data from a server, reading files, waiting for user input. If JavaScript stopped and waited for each operation, your page would freeze.
Asynchronous programming lets JavaScript start a slow task, continue running other code, and come back when the task finishes.
Callbacks — the original approach
JavaScript
function fetchData(callback) {
setTimeout(() => {
callback({ name: 'Alice' });
}, 1000);
}
fetchData((data) => {
console.log(data.name); // "Alice" (after 1 second)
});This works, but nesting callbacks creates "callback hell":
JavaScript
getUser(userId, (user) => {
getOrders(user.id, (orders) => {
getOrderDetails(orders[0].id, (details) => {
// deeply nested, hard to read and debug
});
});
});Promises — a cleaner pattern
A Promise represents a value that isn't available yet but will be eventually:
JavaScript
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve({ name: 'Alice' });
}, 1000);
});
promise
.then((data) => {
console.log(data.name); // "Alice"
})
.catch((error) => {
console.error('Something went wrong:', error);
});Promise states
- Pending — the operation is in progress.
- Fulfilled — the operation succeeded (
.then()runs). - Rejected — the operation failed (
.catch()runs).
Chaining promises
JavaScript
fetch('/api/user')
.then(response => response.json())
.then(user => fetch(`/api/orders/${user.id}`))
.then(response => response.json())
.then(orders => console.log(orders))
.catch(error => console.error(error));Each
.then() returns a new Promise, so you can chain instead of nesting.async/await — the modern way
async/await is syntactic sugar over Promises. It lets you write asynchronous code that looks synchronous:JavaScript
async function loadUser() {
try {
const response = await fetch('/api/user');
const user = await response.json();
const ordersResponse = await fetch(`/api/orders/${user.id}`);
const orders = await ordersResponse.json();
console.log(orders);
} catch (error) {
console.error('Failed to load:', error);
}
}
loadUser();await pauses execution of the function (not the whole program) until the Promise resolves. The code reads top-to-bottom, like synchronous code.Rules of async/await
awaitcan only be used inside anasyncfunction (or at the top level of a module).- An
asyncfunction always returns a Promise. - Always wrap
awaitcalls intry/catchfor error handling.
Running promises in parallel
If two operations don't depend on each other, run them simultaneously:
JavaScript
async function loadDashboard() {
const [user, notifications] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/notifications').then(r => r.json()),
]);
console.log(user, notifications);
}Promise.all() runs all promises at the same time and waits for all to finish. If any one fails, the whole thing fails.Promise.allSettled
If you want to know which succeeded and which failed:
JavaScript
const results = await Promise.allSettled([
fetch('/api/a').then(r => r.json()),
fetch('/api/b').then(r => r.json()),
]);
results.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.log('Failed:', result.reason);
}
});The Fetch API
fetch is the modern way to make HTTP requests:JavaScript
// GET request
const getResponse = await fetch('/api/posts');
const posts = await getResponse.json();
// POST request
const postResponse = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Hello', content: 'World' }),
});
// Check for errors — fetch doesn't throw on 404 or 500
if (!postResponse.ok) {
throw new Error(`HTTP ${postResponse.status}: ${postResponse.statusText}`);
}Important:
fetch only throws on network errors (like no internet). A 404 or 500 response is still a successful fetch — you must check response.ok yourself.Common patterns
Loading state
JavaScript
async function loadPosts() {
setLoading(true);
try {
const response = await fetch('/api/posts');
if (!response.ok) throw new Error('Failed to load');
const posts = await response.json();
setPosts(posts);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
}Retry logic
JavaScript
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (response.ok) return await response.json();
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
}
}
}The event loop
JavaScript runs on a single call stack — one function at a time until it returns. So how can
setTimeout, fetch, and click handlers all feel simultaneous? The browser (or Node.js) provides Web APIs that handle slow work outside the stack. When a timer fires or a network response arrives, the callback is placed in a task queue. The event loop checks: is the call stack empty? If yes, it pulls the next task and runs it.This model explains common surprises.
setTimeout(fn, 0) does not run immediately — it runs after the current script finishes. Long synchronous loops block the page because nothing else can run until the stack clears. That is why heavy computation should be chunked or moved to a Web Worker.Microtasks (Promise
.then callbacks and await continuations) run before the next macrotask (like setTimeout). In practice: after await fetch(...), your code resumes as a microtask once the response is ready, still without blocking user input for the network wait itself.JavaScript
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2Understanding this order helps you debug race conditions and avoid assuming that callbacks run in the order you registered them without checking Promise chains.
Fetch error handling in depth
Treat every
fetch as two separate steps: transport (did the request reach the server?) and application (did the server return a successful result?). fetch only rejects on network failure — offline, DNS errors, CORS blocks in some cases. HTTP 404, 500, and 401 still resolve with response.ok === false.A robust pattern checks status, parses JSON safely, and surfaces meaningful errors:
JavaScript
async function fetchJson(url, options) {
let response;
try {
response = await fetch(url, options);
} catch (error) {
throw new Error(`Network error: ${error.message}`);
}
let body;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
body = await response.json();
} else {
body = await response.text();
}
if (!response.ok) {
const message = typeof body === 'object' && body?.error
? body.error
: `HTTP ${response.status}: ${response.statusText}`;
throw new Error(message);
}
return body;
}Use
AbortController to cancel in-flight requests when the user navigates away or types quickly in a search box. Pass signal: controller.signal in the fetch options and call controller.abort() when the request is obsolete — catch AbortError separately so you do not show it as a failure to the user.Key takeaway
Async programming is essential for web development. Use
async/await for readability, Promise.all for parallel operations, try/catch for error handling, and always check response.ok with fetch. Understanding the event loop and how JavaScript handles asynchronous code is what separates beginners from intermediate developers.