Back to articles
6 min read

Understanding Promises in JavaScript

Learn Promise states, chaining, async/await, concurrency helpers, and common mistakes through practical examples.

JavaScriptDevelopment

Understanding Promises in JavaScript

JavaScript frequently starts operations whose results are not available immediately: HTTP requests, file reads, and timers. A Promise represents the eventual outcome of an asynchronous operation. It helps us handle success and failure without blocking execution while we wait.

In this guide, we will examine Promise states, chaining, async/await, concurrency, and common mistakes. The key point is that async/await does not replace Promises: it provides a more readable way to work with them.

1. Why asynchronous code matters

A network request might take milliseconds or several seconds. JavaScript can do other work while it waits.

console.log('Before');

setTimeout(() => {
  console.log('Finished');
}, 1000);

console.log('After');

Output:

Before
After
Finished

The callback runs later. Promises provide a structured way to represent the result or failure of an asynchronous operation.

2. The three Promise states

A Promise can be:

  • pending: its outcome is not available yet.
  • fulfilled: the operation succeeded, producing a value.
  • rejected: the operation failed, producing a rejection reason.

Once fulfilled or rejected, a Promise is settled and its state cannot change again.

const success = Promise.resolve('Data received');
const failure = Promise.reject(new Error('Service unavailable'));

Handle rejected Promises with .catch() or with try/catch inside an async function to avoid unhandled rejections.

3. Creating a Promise

The Promise constructor takes an executor that receives resolve and reject callbacks.

function wait(milliseconds) {
  return new Promise((resolve) => {
    setTimeout(resolve, milliseconds);
  });
}

wait(1000).then(() => {
  console.log('One second has passed');
});

wait() returns a Promise immediately, which fulfills after the delay. In real applications, you will more often use APIs that already return Promises, such as fetch().

Avoid wrapping a Promise-returning function in another new Promise() without a good reason.

4. then, catch, and finally

Use:

  • .then() to process a successful result.
  • .catch() to handle a rejection or an exception in the chain.
  • .finally() for cleanup regardless of the outcome.
function getUser() {
  return Promise.resolve({ id: 1, name: 'Aziz' });
}

getUser()
  .then((user) => {
    console.log(user.name);
    return user.id;
  })
  .then((id) => {
    console.log('User ID:', id);
  })
  .catch((error) => {
    console.error('Something went wrong:', error);
  })
  .finally(() => {
    console.log('Processing finished');
  });

Each call to .then(), .catch(), or .finally() returns a new Promise, which makes chaining possible.

Why returning a Promise matters

If you start an asynchronous operation inside .then() without returning it, the next step does not wait for that operation to finish.

// Incorrect: the next step does not wait for saveUser().
getUser()
  .then((user) => {
    saveUser(user);
  })
  .then(() => console.log('Assuming the save is complete'));

// Correct: the chain waits for the returned Promise.
getUser()
  .then((user) => saveUser(user))
  .then(() => console.log('Save completed'));

Here, saveUser stands for an asynchronous function returning a Promise.

5. async and await

An async function always returns a Promise, even if it returns a plain value.

async function answer() {
  return 42;
}

answer().then(console.log); // 42

await waits for a Promise-like value to settle and produces its fulfilled value. If the Promise rejects, await throws.

async function showUser() {
  try {
    const user = await getUser();
    console.log(user.name);
  } catch (error) {
    console.error('Could not load user:', error);
  } finally {
    console.log('Loading finished');
  }
}

Important: await suspends the enclosing async function; it does not block the entire JavaScript runtime.

6. Real-world example: fetch()

fetch() returns a Promise that fulfills with a Response. Reading the body, for example with response.json(), is also asynchronous.

interface User {
  id: number;
  name: string;
}

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);

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

  return (await response.json()) as User;
}

A critical distinction: fetch() does not reject merely because the HTTP status is 404 or 500. Check response.ok or response.status yourself. A network failure may reject the Promise.

And response.json() can fail if the body is not valid JSON, so error handling still matters.

7. Serial versus concurrent operations

Sequential await expressions usually start operations one after another:

const profile = await fetchProfile();
const settings = await fetchSettings();

If the operations do not depend on each other, start them together:

const [profile, settings] = await Promise.all([
  fetchProfile(),
  fetchSettings()
]);

This lets both operations make progress concurrently. Promise.all() rejects as soon as any input Promise rejects; it does not automatically cancel the other operations already running.

What if one service can fail independently?

Use Promise.allSettled(). It waits for every Promise to settle and reports each outcome.

type Rating = { rapid: number };
type Gaming = { gamerscore: number };

declare function getChessStats(): Promise<Rating>;
declare function getXboxStats(): Promise<Gaming>;

const [chessResult, xboxResult] = await Promise.allSettled([
  getChessStats(),
  getXboxStats()
]);

const chess = chessResult.status === 'fulfilled'
  ? chessResult.value
  : null;

const xbox = xboxResult.status === 'fulfilled'
  ? xboxResult.value
  : null;

This pattern is useful for a portfolio: if Chess.com is unavailable, the Xbox card can still show its statistics.

Other useful helpers include:

Method Behavior
Promise.all() Waits for all successful results; rejects on the first rejection.
Promise.allSettled() Waits for every success or failure.
Promise.race() Settles as soon as the first input Promise settles.
Promise.any() Fulfills on the first success; rejects if all inputs reject.

8. Four common mistakes

Forgetting await

const response = fetch('/api/users');
console.log(response.status); // undefined: this is a Promise

Instead:

const response = await fetch('/api/users');
console.log(response.status);

Ignoring rejections

async function loadData() {
  const response = await fetch('/api/data');
  if (!response.ok) throw new Error('HTTP request failed');
  return response.json();
}

try {
  const data = await loadData();
  console.log(data);
} catch (error) {
  console.error(error);
}

Assuming Promise.all() cancels requests

A rejection stops waiting for the group to succeed; it does not cancel running tasks by itself. Where appropriate, AbortController can cancel a fetch() request.

Confusing a cache with a Promise

A Promise represents a pending or completed asynchronous outcome. A cache retains a value for reuse. However, caching an in-flight Promise can help several callers share the same network request: this is called request deduplication.

9. A short exercise

Imagine a dashboard that needs to:

  1. Fetch a user's profile.
  2. Fetch notifications independently.
  3. Display whichever result is available, even if the other call fails.

Which helper would you choose? Promise.allSettled(), because it reports both successes and failures without treating one failed request as a failure of the entire group.

Conclusion

Promises underpin asynchronous JavaScript. Remember these four principles:

  • A Promise represents a future value or a future failure.
  • .then() chains operations while .catch() handles rejections.
  • async/await improves readability but still uses Promises.
  • Promise.all() and Promise.allSettled() address different needs: all results succeeding together versus collecting all outcomes, including partial failures.

Experiment with small asynchronous functions and watch their execution order. Once these ideas click, patterns such as server-side caching become much easier to understand.