JavaScript Promises: Complete Guide with Examples

JavaScript's single-threaded nature requires asynchronous patterns for operations like API calls, file I/O, and timers. Before Promises, callbacks were the primary mechanism, leading to deeply nested...

Key Insights

  • Promises provide a cleaner alternative to callbacks for handling asynchronous operations, with three states (pending, fulfilled, rejected) that represent the lifecycle of an async operation
  • Modern async/await syntax is syntactic sugar over Promises that makes asynchronous code read like synchronous code while maintaining all Promise functionality
  • Understanding Promise static methods (all, race, allSettled, any) is critical for managing multiple concurrent operations efficiently in production applications

Introduction to Promises

JavaScript’s single-threaded nature requires asynchronous patterns for operations like API calls, file I/O, and timers. Before Promises, callbacks were the primary mechanism, leading to deeply nested code known as “callback hell.”

Here’s the difference:

// Callback hell
fetchUser(userId, (userError, user) => {
  if (userError) {
    handleError(userError);
    return;
  }
  fetchPosts(user.id, (postsError, posts) => {
    if (postsError) {
      handleError(postsError);
      return;
    }
    fetchComments(posts[0].id, (commentsError, comments) => {
      if (commentsError) {
        handleError(commentsError);
        return;
      }
      displayData(user, posts, comments);
    });
  });
});

// Promise-based approach
fetchUser(userId)
  .then(user => fetchPosts(user.id))
  .then(posts => fetchComments(posts[0].id))
  .then(comments => displayData(comments))
  .catch(error => handleError(error));

Promises represent a value that will be available in the future. They provide a standardized interface for async operations with built-in error handling and composability.

Promise Fundamentals

A Promise exists in one of three states:

  • Pending: Initial state, operation hasn’t completed
  • Fulfilled: Operation completed successfully
  • Rejected: Operation failed

The Promise constructor takes an executor function with resolve and reject callbacks:

const simplePromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Operation completed');
  }, 1000);
});

// Promise that resolves with data
const fetchUserData = new Promise((resolve, reject) => {
  const userData = { id: 1, name: 'Alice', email: 'alice@example.com' };
  resolve(userData);
});

// Promise that rejects with an error
const failingOperation = new Promise((resolve, reject) => {
  const error = new Error('Database connection failed');
  reject(error);
});

Once a Promise settles (fulfills or rejects), its state is immutable. You cannot resolve a rejected Promise or vice versa.

Consuming Promises: then, catch, and finally

The .then() method handles fulfilled Promises, .catch() handles rejections, and .finally() runs regardless of outcome:

fetchUserData
  .then(user => {
    console.log('User:', user.name);
    return user.id;
  })
  .catch(error => {
    console.error('Error:', error.message);
  });

// Chaining multiple operations
fetch('https://api.example.com/users/1')
  .then(response => response.json())
  .then(user => {
    console.log('Fetched user:', user);
    return fetch(`https://api.example.com/posts?userId=${user.id}`);
  })
  .then(response => response.json())
  .then(posts => {
    console.log('User posts:', posts);
  })
  .catch(error => {
    console.error('Chain failed:', error);
  });

// Using finally for cleanup
let loadingIndicator = true;

fetchData()
  .then(data => processData(data))
  .catch(error => logError(error))
  .finally(() => {
    loadingIndicator = false;
    console.log('Cleanup complete');
  });

Each .then() returns a new Promise, enabling chaining. If you return a value from .then(), it’s wrapped in a resolved Promise. If you return a Promise, the chain waits for it.

Promise Static Methods

Promise static methods handle multiple Promises concurrently with different semantics:

Promise.all() - Waits for all Promises to fulfill or any to reject:

const apiCalls = [
  fetch('https://api.example.com/users'),
  fetch('https://api.example.com/posts'),
  fetch('https://api.example.com/comments')
];

Promise.all(apiCalls)
  .then(responses => Promise.all(responses.map(r => r.json())))
  .then(([users, posts, comments]) => {
    console.log('All data loaded:', { users, posts, comments });
  })
  .catch(error => {
    console.error('At least one call failed:', error);
  });

Promise.race() - Resolves or rejects with the first settled Promise:

function fetchWithTimeout(url, timeout = 5000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Request timeout')), timeout);
  });

  return Promise.race([
    fetch(url),
    timeoutPromise
  ]);
}

fetchWithTimeout('https://api.example.com/slow-endpoint', 3000)
  .then(response => response.json())
  .catch(error => console.error('Failed or timed out:', error));

Promise.allSettled() - Waits for all Promises to settle, regardless of outcome:

const mixedPromises = [
  fetch('https://api.example.com/valid'),
  fetch('https://api.example.com/invalid'),
  Promise.resolve({ cached: true })
];

Promise.allSettled(mixedPromises)
  .then(results => {
    results.forEach((result, index) => {
      if (result.status === 'fulfilled') {
        console.log(`Promise ${index} succeeded:`, result.value);
      } else {
        console.log(`Promise ${index} failed:`, result.reason);
      }
    });
  });

Promise.any() - Resolves with the first fulfilled Promise, ignoring rejections:

const mirrors = [
  fetch('https://mirror1.example.com/data'),
  fetch('https://mirror2.example.com/data'),
  fetch('https://mirror3.example.com/data')
];

Promise.any(mirrors)
  .then(response => response.json())
  .then(data => console.log('Got data from fastest mirror:', data))
  .catch(error => console.error('All mirrors failed:', error));

Async/Await: Modern Promise Syntax

Async/await makes asynchronous code look synchronous, improving readability dramatically:

// Promise chain
function getUserPosts(userId) {
  return fetchUser(userId)
    .then(user => fetchPosts(user.id))
    .then(posts => posts.filter(p => p.published))
    .catch(error => {
      console.error('Error:', error);
      throw error;
    });
}

// Async/await equivalent
async function getUserPosts(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    return posts.filter(p => p.published);
  } catch (error) {
    console.error('Error:', error);
    throw error;
  }
}

Error handling with try/catch is more intuitive than chaining .catch():

async function loadDashboard() {
  try {
    const user = await fetchCurrentUser();
    const [posts, notifications, settings] = await Promise.all([
      fetchUserPosts(user.id),
      fetchNotifications(user.id),
      fetchUserSettings(user.id)
    ]);
    
    return { user, posts, notifications, settings };
  } catch (error) {
    if (error.status === 401) {
      redirectToLogin();
    } else {
      showErrorMessage('Failed to load dashboard');
    }
    throw error;
  }
}

Parallel execution with async/await requires explicit Promise.all():

// Sequential - slow (waits for each)
async function sequentialFetch() {
  const users = await fetch('/users').then(r => r.json());
  const posts = await fetch('/posts').then(r => r.json());
  const comments = await fetch('/comments').then(r => r.json());
  return { users, posts, comments };
}

// Parallel - fast (concurrent requests)
async function parallelFetch() {
  const [users, posts, comments] = await Promise.all([
    fetch('/users').then(r => r.json()),
    fetch('/posts').then(r => r.json()),
    fetch('/comments').then(r => r.json())
  ]);
  return { users, posts, comments };
}

Common Patterns and Best Practices

Always return Promises in chains to avoid silent failures:

// Wrong - forgotten return
fetchUser(userId)
  .then(user => {
    fetchPosts(user.id); // Missing return!
  })
  .then(posts => {
    console.log(posts); // undefined
  });

// Correct
fetchUser(userId)
  .then(user => {
    return fetchPosts(user.id);
  })
  .then(posts => {
    console.log(posts); // Works correctly
  });

Propagate errors properly by rethrowing or handling them:

async function processData(id) {
  try {
    const data = await fetchData(id);
    return await transformData(data);
  } catch (error) {
    // Log but rethrow for upstream handling
    console.error('Processing failed:', error);
    throw new Error(`Failed to process data for ${id}: ${error.message}`);
  }
}

Choose sequential vs. parallel execution based on dependencies:

// Sequential when operations depend on each other
async function createUserWorkflow(userData) {
  const user = await createUser(userData);
  const profile = await createProfile(user.id);
  const settings = await initializeSettings(profile.id);
  return { user, profile, settings };
}

// Parallel when operations are independent
async function loadPageData(userId) {
  const [user, posts, followers] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
    fetchFollowers(userId)
  ]);
  return { user, posts, followers };
}

Real-World Application

Here’s a production-ready data fetching utility with retries, timeouts, and comprehensive error handling:

class APIClient {
  constructor(baseURL, options = {}) {
    this.baseURL = baseURL;
    this.timeout = options.timeout || 5000;
    this.retries = options.retries || 3;
  }

  async fetchWithRetry(endpoint, options = {}, attempt = 1) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), this.timeout);

    try {
      const response = await fetch(`${this.baseURL}${endpoint}`, {
        ...options,
        signal: controller.signal
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      clearTimeout(timeoutId);

      if (attempt < this.retries && this.shouldRetry(error)) {
        console.warn(`Retry ${attempt}/${this.retries} for ${endpoint}`);
        await this.delay(Math.pow(2, attempt) * 1000);
        return this.fetchWithRetry(endpoint, options, attempt + 1);
      }

      throw error;
    }
  }

  shouldRetry(error) {
    return error.name === 'AbortError' || 
           error.message.includes('Network') ||
           error.message.includes('500');
  }

  delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  async fetchMultiple(endpoints) {
    const results = await Promise.allSettled(
      endpoints.map(endpoint => this.fetchWithRetry(endpoint))
    );

    return results.map((result, index) => ({
      endpoint: endpoints[index],
      success: result.status === 'fulfilled',
      data: result.status === 'fulfilled' ? result.value : null,
      error: result.status === 'rejected' ? result.reason.message : null
    }));
  }
}

// Usage
const api = new APIClient('https://api.example.com', {
  timeout: 3000,
  retries: 3
});

async function loadUserDashboard(userId) {
  try {
    const results = await api.fetchMultiple([
      `/users/${userId}`,
      `/users/${userId}/posts`,
      `/users/${userId}/notifications`
    ]);

    const [userData, postsData, notificationsData] = results;

    if (!userData.success) {
      throw new Error('Failed to load user data');
    }

    return {
      user: userData.data,
      posts: postsData.success ? postsData.data : [],
      notifications: notificationsData.success ? notificationsData.data : []
    };
  } catch (error) {
    console.error('Dashboard load failed:', error);
    throw error;
  }
}

This implementation demonstrates retry logic with exponential backoff, timeout handling, parallel requests with partial failure tolerance, and comprehensive error reporting—all patterns essential for production JavaScript applications.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.