JavaScript

Async JavaScript Explained: Promises, Async/Await, and Common Pitfalls

Master asynchronous JavaScript — understand the event loop, Promises, async/await, error handling, concurrency patterns, and the mistakes that trip up experienced developers.

March 17, 20264 min read

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 rejected
  • fulfilled — operation completed successfully
  • rejected — 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:
  • await can only be used inside async functions (or at the top level of ES modules)
  • async functions 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

Sequential (slow):
// 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

1. forEach doesn't work with async:
// 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();

javascriptasyncpromisesasync awaitconcurrencyevent looptutorial

Related Articles