The Event Loop in 30 Seconds
JavaScript is single-threaded but non-blocking. It handles async operations through the event loop:
- 1. Your code runs synchronously on the call stack
- 2. Async operations (fetch, setTimeout, file I/O) go to browser/Node APIs
- 3. When they complete, callbacks go to the task queue
- 4. The event loop moves tasks from the queue to the call stack when it's empty
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// Output: 1, 4, 3, 2
Why this order? Microtasks (Promises) run before macrotasks (setTimeout), even if the timeout is 0ms. This matters when you're debugging timing issues.
Promises: The Foundation
A Promise represents a value that may not exist yet:
// Creating a Promise
function fetchUser(id) {
return new Promise((resolve, reject) => {
if (!id) reject(new Error('ID required'));
// Simulate API call
setTimeout(() => resolve({ id, name: 'Alice' }), 100);
});
}
// Consuming with .then/.catch
fetchUser(1)
.then(user => console.log(user))
.catch(error => console.error(error));
Promise states:
pending— initial state, not yet resolved or rejectedfulfilled— operation completed successfullyrejected— operation failed
A Promise can only transition once: pending → fulfilled OR pending → rejected. It cannot change back.
Async/Await: The Better Syntax
Async/await is syntactic sugar over Promises that makes async code look synchronous:
// With Promises
function getUser() {
return fetch('/api/user')
.then(res => res.json())
.then(user => fetch(/api/posts?userId=${user.id}))
.then(res => res.json())
.then(posts => ({ user, posts }));
}
// With async/await (same behavior, much cleaner)
async function getUser() {
const userRes = await fetch('/api/user');
const user = await userRes.json();
const postsRes = await fetch(/api/posts?userId=${user.id});
const posts = await postsRes.json();
return { user, posts };
}
Rules:
awaitcan only be used insideasyncfunctions (or at the top level of ES modules)asyncfunctions always return a Promise- If an awaited Promise rejects, it throws — use try/catch
Error Handling Patterns
The most common async bug is unhandled rejections. Always handle errors:
// Pattern 1: try/catch (most common)
async function loadData() {
try {
const data = await fetchJSON('/api/data');
return data;
} catch (error) {
console.error('Failed to load:', error.message);
return null; // or throw to propagate
}
}
// Pattern 2: Wrapper function (avoids try/catch boilerplate)
async function safe(promise) {
try {
const result = await promise;
return [result, null];
} catch (error) {
return [null, error];
}
}
const [user, error] = await safe(fetchUser(1));
if (error) {
console.error('Failed:', error.message);
}
Never do this:
// BAD: swallows all errors silently
async function loadData() {
const data = await fetch('/api/data').catch(() => {});
// data is undefined on error — leads to confusing bugs later
}
Concurrency: Running Async Operations in Parallel
// Takes ~3 seconds (1s + 1s + 1s)
const user = await fetchUser();
const posts = await fetchPosts();
const comments = await fetchComments();
Parallel (fast):
// Takes ~1 second (all run simultaneously)
const [user, posts, comments] = await Promise.all([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
Parallel with error tolerance:
// Don't fail everything if one request fails
const results = await Promise.allSettled([
fetchUser(),
fetchPosts(),
fetchComments(),
]);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('Success:', result.value);
} else {
console.log('Failed:', result.reason);
}
});
Use Promise.all when: all results are needed — if one fails, you want everything to fail.
Use Promise.allSettled when: partial results are useful — some can fail without breaking the page.Common Pitfalls That Trip Up Everyone
// BAD: doesn't wait for any of these
ids.forEach(async (id) => {
await processItem(id); // Fire-and-forget!
});
// GOOD: use for...of for sequential
for (const id of ids) {
await processItem(id);
}
// GOOD: use Promise.all for parallel
await Promise.all(ids.map(id => processItem(id)));
2. Forgetting await:
// BAD: user is a Promise, not the data
const user = fetchUser(); // Missing await!
console.log(user.name); // undefined
// GOOD
const user = await fetchUser();
console.log(user.name); // 'Alice'
3. Sequential awaits in loops when parallel is fine:
// SLOW: processes one at a time
for (const url of urls) {
const data = await fetch(url);
}
// FAST: fetches all in parallel
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
4. Async function in a constructor:
// BAD: constructors can't be async
class DB {
constructor() {
this.connection = await connect(); // SyntaxError!
}
}
// GOOD: use a static factory method
class DB {
static async create() {
const db = new DB();
db.connection = await connect();
return db;
}
}
const db = await DB.create();