DEV Community

Harman Panwar
Harman Panwar

Posted on

JavaScript Promises Explained for Beginners

Understanding JavaScript Promises: From Callback Chaos to Clean Async Code

Asynchronous operations are the backbone of modern JavaScript — fetching data from APIs, reading files, querying databases. But for years, developers struggled with a pattern that turned simple logic into unreadable nesting: callbacks. Promises were introduced in ES6 (2015) to solve this problem, offering a structured way to handle operations that complete in the future. This guide explains what promises solve, how they work, and why they transformed the way we write async code.


What Problem Promises Solve

The Callback Problem

Before promises, JavaScript handled asynchronous operations using callbacks — functions passed as arguments to be executed later. This worked for simple cases, but collapsed under real-world complexity.

Callback Hell (The Pyramid of Doom)

Imagine you need to: fetch a user → fetch their orders → fetch order details → save a summary. With callbacks:

getUser(userId, function(user, err) {
  if (err) {
    console.error('Failed to get user:', err);
    return;
  }
  getOrders(user.id, function(orders, err) {
    if (err) {
      console.error('Failed to get orders:', err);
      return;
    }
    getOrderDetails(orders[0].id, function(details, err) {
      if (err) {
        console.error('Failed to get details:', err);
        return;
      }
      saveSummary(details, function(result, err) {
        if (err) {
          console.error('Failed to save:', err);
          return;
        }
        console.log('Summary saved:', result);
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

The pain points:

  • Deep nesting: Each async step adds another indentation level, creating the "pyramid" shape
  • Error handling repetition: Every callback needs its own if (err) check
  • Inversion of control: You pass your logic into someone else's function, trusting it to call you back correctly (once, not twice, not never) [^10^]
  • No return values: You can't return from a callback and use that value elsewhere
  • Difficult to compose: Running operations in parallel or sequencing them cleanly is nearly impossible

The Promise Solution

Promises solve these problems by introducing a standardized object that represents the eventual result of an asynchronous operation. Instead of passing callbacks into functions, functions return a promise — an object you attach handlers to.

getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => saveSummary(details))
  .then(result => console.log('Summary saved:', result))
  .catch(err => console.error('Operation failed:', err));
Enter fullscreen mode Exit fullscreen mode

What changed:

  • Flat structure: No nesting, just a vertical chain
  • Centralized errors: One .catch() handles any failure in the entire chain
  • Return values flow: Each .then() receives the previous result
  • Composable: You can pass promises around, return them from functions, combine them with Promise.all()

Promises as a Future Value

The most powerful mental model for promises is this: a promise is a container for a value that doesn't exist yet, but will [^1^].

The Restaurant Receipt Analogy

Imagine you walk into a busy restaurant and order a meal. You don't get the food immediately — instead, you receive a receipt with an order number. This receipt is a promise: it represents food you will receive in the future. While waiting, you can sit down, chat with friends, or check your phone. You don't stand at the counter blocking everyone else.

The receipt has three possible states:

  • Pending: Your order is being prepared (initial state)
  • Fulfilled: Your meal is ready (success)
  • Rejected: The kitchen ran out of ingredients (failure)

A JavaScript promise works exactly the same way:

const mealPromise = new Promise((resolve, reject) => {
  // The kitchen starts cooking
  setTimeout(() => {
    const ingredientsAvailable = true;

    if (ingredientsAvailable) {
      resolve('🍔 Burger and fries'); // Fulfilled
    } else {
      reject('❌ Out of patties');    // Rejected
    }
  }, 2000); // Cooking takes 2 seconds
});

// You receive the receipt immediately (a promise object)
console.log(mealPromise); // Promise {<pending>}

// Attach handlers for when the food is ready
mealPromise
  .then(meal => console.log('Enjoy your:', meal))
  .catch(reason => console.error('Sorry:', reason));

// You can do other things while waiting...
console.log('Chatting with friends while waiting...');
Enter fullscreen mode Exit fullscreen mode

Key insight: The promise object is returned immediately. The actual value (the burger) arrives later. This separation of "receiving the container" from "receiving the contents" is what makes promises non-blocking and composable.


Promise States

According to the MDN Web Docs, a Promise is always in one of three states [^1^]:

State Description Can Change?
Pending Initial state. The operation is in progress. Neither fulfilled nor rejected. Yes → moves to fulfilled or rejected
Fulfilled The operation completed successfully. The promise has a value. No — settled forever
Rejected The operation failed. The promise has a reason (error). No — settled forever

A promise that is either fulfilled or rejected is called settled. Once settled, a promise's state and value are immutable — they never change again. This is a critical guarantee: you can attach .then() handlers at any time, and they will fire with the correct result, even if the promise already settled.

Visualizing State Transitions

┌─────────┐    resolve(value)     ┌─────────────┐
│ Pending │ ────────────────────→ │  Fulfilled  │
│         │                       │  (value)    │
│         │    reject(reason)       │             │
│         │ ────────────────────→ │  Rejected   │
└─────────┘                       │  (reason)   │
                                  └─────────────┘
                                   (settled)
Enter fullscreen mode Exit fullscreen mode

Checking State in Practice

const promise = fetch('https://api.example.com/user');

// Immediately after creation — always pending
console.log(promise); // Promise {<pending>}

// Later, it will settle (you can't synchronously check when)
promise.then(data => {
  // Now it's fulfilled
  console.log('Fulfilled with:', data);
}).catch(err => {
  // Or rejected
  console.error('Rejected with:', err);
});
Enter fullscreen mode Exit fullscreen mode

Basic Promise Lifecycle

Creating a Promise

You create a promise using the Promise constructor, which takes an executor function with two parameters: resolve and reject.

const myPromise = new Promise((resolve, reject) => {
  // This executor runs immediately and synchronously

  const success = true;

  if (success) {
    resolve('Operation completed!'); // Transition to fulfilled
  } else {
    reject(new Error('Something went wrong')); // Transition to rejected
  }
});
Enter fullscreen mode Exit fullscreen mode

Important rules:

  • The executor runs immediately when the promise is created
  • resolve and reject can only be called once — the first call wins, subsequent calls are ignored
  • If the executor throws an error, the promise is automatically rejected

Wrapping Callback-Based Code

Many older APIs still use callbacks. You can wrap them in promises:

const fs = require('fs');

// Callback version
fs.readFile('config.json', 'utf8', (err, data) => {
  if (err) console.error(err);
  else console.log(data);
});

// Promise wrapper
function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

// Now you can use .then() and .catch()
readFilePromise('config.json')
  .then(data => console.log(data))
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

Handling Success and Failure

The .then() Method

.then() attaches handlers for when the promise settles. It takes two optional arguments:

promise.then(
  onFulfilled,  // Called if promise is fulfilled
  onRejected    // Called if promise is rejected (rarely used)
);
Enter fullscreen mode Exit fullscreen mode

In practice, use .then() for success and .catch() for errors:

fetchUser(123)
  .then(user => {
    console.log('Found user:', user.name);
    return user.id;
  })
  .catch(err => {
    console.error('Failed to fetch user:', err.message);
  });
Enter fullscreen mode Exit fullscreen mode

The .catch() Method

.catch() is syntactic sugar for .then(null, onRejected). It catches any rejection in the chain:

fetchUser(123)
  .then(user => fetchOrders(user.id))
  .then(orders => processOrders(orders))
  .then(result => saveToDatabase(result))
  .catch(err => {
    // This catches ANY error from ANY step above
    console.error('Chain failed at some point:', err);
  });
Enter fullscreen mode Exit fullscreen mode

Critical benefit: One .catch() replaces five separate if (err) checks in nested callbacks.

The .finally() Method

.finally() runs regardless of whether the promise fulfilled or rejected — perfect for cleanup [^2^]:

showLoadingSpinner();

fetchData()
  .then(data => renderPage(data))
  .catch(err => showError(err))
  .finally(() => {
    // Always hides the spinner, success or failure
    hideLoadingSpinner();
  });
Enter fullscreen mode Exit fullscreen mode

Error Handling Comparison

Scenario Callbacks Promises
Single operation if (err) in callback .catch()
Three chained operations Three if (err) checks One .catch() at the end
Error in middle of chain Manual propagation Automatic propagation down the chain
Cleanup (always run) Duplicate in success and error paths .finally() runs once

Promise Chaining Concept

Promise chaining is the feature that truly separates promises from callbacks. Each .then() returns a new promise, allowing you to link operations sequentially.

How Chaining Works

Promise.resolve(2)           // Creates fulfilled promise with value 2
  .then(x => x * 2)         // Returns 4 (new fulfilled promise)
  .then(x => x + 1)         // Returns 5 (new fulfilled promise)
  .then(x => console.log(x)); // Logs: 5
Enter fullscreen mode Exit fullscreen mode

The rules of chaining:

  1. Return a value → next .then() receives that value
  2. Return a promise → next .then() waits for it and receives its value
  3. Throw an error → next .catch() catches it
  4. Return nothing → next .then() receives undefined

Real-World Chaining Example

function fetchUser(id) {
  return fetch(`/api/users/${id}`).then(res => res.json());
}

function fetchOrders(userId) {
  return fetch(`/api/orders?user=${userId}`).then(res => res.json());
}

function calculateTotal(orders) {
  return orders.reduce((sum, order) => sum + order.amount, 0);
}

// Clean sequential flow
fetchUser(123)
  .then(user => {
    console.log(`Hello ${user.name}`);
    return fetchOrders(user.id); // Returns promise, chain waits
  })
  .then(orders => {
    console.log(`Found ${orders.length} orders`);
    return calculateTotal(orders); // Returns number, chain continues
  })
  .then(total => {
    console.log(`Total spent: $${total}`);
  })
  .catch(err => {
    console.error('Something went wrong:', err);
  });
Enter fullscreen mode Exit fullscreen mode

Chaining vs Callbacks: Readability Comparison

Aspect Callbacks Promise Chain
Visual structure Pyramid (→ indented right) Vertical list (↓ straight down)
Error handling Scattered if (err) blocks One .catch() at the bottom
Return values Lost in callback scope Flow through chain automatically
Adding a step Nest another level Add another .then()
Parallel execution Complex manual coordination Promise.all([p1, p2, p3])

Promises vs Callbacks: A Complete Comparison

The Same Operation, Two Ways

Task: Read a config file, parse it, connect to a database, and fetch initial data.

Callback Approach (The Old Way)

fs.readFile('config.json', 'utf8', (err, configData) => {
  if (err) {
    console.error('Config read failed:', err);
    return;
  }

  let config;
  try {
    config = JSON.parse(configData);
  } catch (parseErr) {
    console.error('Invalid JSON:', parseErr);
    return;
  }

  db.connect(config.dbUrl, (connErr, connection) => {
    if (connErr) {
      console.error('DB connection failed:', connErr);
      return;
    }

    connection.query('SELECT * FROM settings', (queryErr, results) => {
      if (queryErr) {
        console.error('Query failed:', queryErr);
        connection.close();
        return;
      }

      console.log('Initial data:', results);
      connection.close();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Line count: ~30 lines | Indentation: 4 levels deep | Error checks: 4 separate blocks

Promise Approach (The Modern Way)

readFilePromise('config.json')
  .then(configData => JSON.parse(configData))
  .then(config => db.connectPromise(config.dbUrl))
  .then(connection => {
    return connection.queryPromise('SELECT * FROM settings')
      .then(results => {
        console.log('Initial data:', results);
        return connection; // Pass connection to finally
      });
  })
  .then(connection => connection.close())
  .catch(err => console.error('Operation failed:', err));
Enter fullscreen mode Exit fullscreen mode

Line count: ~12 lines | Indentation: 1 level | Error checks: 1 .catch() block

Readability Improvements

Improvement How Promises Deliver
Linear flow Code reads top-to-bottom like synchronous logic
Error consolidation One handler for any failure point
Value propagation No need to declare variables in outer scope
Return semantics return actually passes values forward
Composable units Each .then() callback is a standalone, testable function

Common Promise Patterns

Running Operations in Parallel

const userPromise = fetchUser(123);
const ordersPromise = fetchOrders(123);
const settingsPromise = fetchSettings(123);

Promise.all([userPromise, ordersPromise, settingsPromise])
  .then(([user, orders, settings]) => {
    console.log('All data loaded');
    renderDashboard(user, orders, settings);
  })
  .catch(err => console.error('One request failed:', err));
Enter fullscreen mode Exit fullscreen mode

Racing for the Fastest Result

// Use the first response from either cache or network
Promise.race([
  fetchFromCache(url),
  fetchFromNetwork(url)
])
.then(data => render(data));
Enter fullscreen mode Exit fullscreen mode

Sequential Execution with Dynamic Data

const urls = ['/api/posts/1', '/api/posts/2', '/api/posts/3'];

// Process sequentially, not in parallel
urls.reduce((promiseChain, url) => {
  return promiseChain.then(results => {
    return fetch(url)
      .then(res => res.json())
      .then(post => [...results, post]);
  });
}, Promise.resolve([]))
.then(allPosts => console.log('All posts:', allPosts));
Enter fullscreen mode Exit fullscreen mode

Summary

Concept Promise Approach Benefit
Future value Object representing eventual result Non-blocking, composable
States Pending → Fulfilled/Rejected Predictable lifecycle, immutable once settled
Success handling .then() Clean value propagation
Failure handling .catch() Centralized error management
Cleanup .finally() Runs regardless of outcome
Chaining Each .then() returns a new promise Sequential async operations read linearly
Parallel Promise.all(), Promise.race() Built-in concurrency primitives

Promises didn't just add a new API to JavaScript — they introduced a new way of thinking about asynchronous code. By treating async operations as values that can be passed around, combined, and chained, promises transformed callback-heavy spaghetti into readable, maintainable, and composable code. Whether you use .then() chains or the newer async/await syntax (which is built entirely on promises), understanding this foundation is essential for modern JavaScript development.

Remember: A promise is a receipt for something you don't have yet. You can hold it, pass it around, and attach handlers knowing that when the work completes — successfully or not — you'll be notified. That's the power of treating the future as a first-class value.

Top comments (0)