Understanding finally() with nested promises in JS

When working with asynchronous JavaScript, a common misconception is that a finally block on an outer promise will wait for all nested promises to complete. This assumption can lead to subtle bugs and unexpected execution order in your code.

The Problem

Consider this code:

promise1().then(() => {
  promise2().then(() => {
    // something
  })
  .catch(e => console.error(e));
})
.catch(e => console.error(e))
.finally(() => console.log('all is completed'));

When does the finally block execute?

Most developers expect it to run after both promise1() and promise2() complete. However, it actually executes as soon as promise1() finishes, regardless of whether promise2() is still running.

Why This Happens

The key to understanding this behavior lies in how promise chains work:

  1. Promise chains only wait for returned promises
  2. When you don’t return a promise from a .then() handler, the outer chain doesn’t know about it
  3. The outer promise resolves immediately after the .then() callback executes, not after the callback’s internal async operations complete

Execution Order Breakdown

// Step 1: promise1() starts
promise1().then(() => {
  // Step 2: promise1() resolved, this callback executes
  
  // Step 3: promise2() starts (but is NOT returned)
  promise2().then(() => {
    // Step 5: promise2() resolves (happens later)
    console.log('promise2 done');
  })
  .catch(e => console.error(e));
  
  // Step 4: This callback finishes immediately
  // The outer promise chain doesn't wait for promise2()
})
.catch(e => console.error(e))
.finally(() => {
  // Step 4: Executes right after the .then() callback returns
  console.log('all is completed'); // ⚠️ Runs before promise2() finishes!
});

Console output:

all is completed
promise2 done

The Solution: Return Nested Promises

To make the outer promise chain wait for nested promises, you must return them:

promise1().then(() => {
  // Return the inner promise
  return promise2().then(() => {
    console.log('promise2 done');
  })
  .catch(e => console.error(e));
})
.catch(e => console.error(e))
.finally(() => {
  console.log('all is completed'); // ✅ Now runs after both promises
});

Console output:

promise2 done
all is completed

Better Approach: async/await

Modern JavaScript provides a cleaner syntax with async/await, making the execution order more intuitive:

(async () => {
  try {
    await promise1();
    await promise2();
    console.log('promise2 done');
  } catch (e) {
    console.error(e);
  } finally {
    console.log('all is completed'); // ✅ Guaranteed to run last
  }
})();

The finally block in async/await works identically to the try...catch...finally statement in synchronous code, making it easier to reason about.

Technical Deep Dive

Promise Resolution Mechanics

When a .then() handler returns a value:

  • Returns a non-promise value: The outer promise resolves immediately with that value
  • Returns a promise: The outer promise waits for that promise to settle
  • Returns nothing (undefined): The outer promise resolves immediately with undefined
// Example 1: Returns a promise - outer chain WAITS
promise1().then(() => {
  return promise2(); // ✅ Outer chain waits
});

// Example 2: Doesn't return - outer chain DOESN'T WAIT
promise1().then(() => {
  promise2(); // ⚠️ Outer chain continues immediately
});

// Example 3: Returns a value - outer chain DOESN'T WAIT for promise2
promise1().then(() => {
  promise2();
  return 'done'; // ⚠️ Outer chain resolves with 'done', ignores promise2
});

The finally() Guarantee

The finally() handler has one guarantee: it will execute after the promise settles (resolves or rejects), but it doesn’t care about any non-returned async operations started within the promise chain.

Promise.resolve()
  .then(() => {
    // Start background operation (not returned)
    setTimeout(() => console.log('Background task done'), 1000);
  })
  .finally(() => {
    console.log('Finally block'); // Runs immediately, doesn't wait for setTimeout
  });

Common Pitfalls

Pitfall 1: Fire-and-Forget Operations

// ❌ BAD: finally() runs before saveToDatabase completes
fetchData()
  .then(data => {
    saveToDatabase(data); // Not returned!
  })
  .finally(() => {
    console.log('Done'); // Misleading - database save might not be done
  });

// ✅ GOOD: Wait for database save
fetchData()
  .then(data => {
    return saveToDatabase(data); // Returned!
  })
  .finally(() => {
    console.log('Done'); // Actually done
  });

Pitfall 2: Multiple Parallel Operations

// ❌ BAD: finally() doesn't wait for all operations
loadData()
  .then(() => {
    operation1();
    operation2();
    operation3();
  })
  .finally(() => {
    console.log('All operations complete'); // Wrong!
  });

// ✅ GOOD: Use Promise.all()
loadData()
  .then(() => {
    return Promise.all([
      operation1(),
      operation2(),
      operation3()
    ]);
  })
  .finally(() => {
    console.log('All operations complete'); // Correct!
  });

Best Practices

  1. Always return promises from .then() handlers when you want the outer chain to wait
  2. Use async/await for better readability and fewer mistakes
  3. Use Promise.all() for parallel operations
  4. Use Promise.allSettled() when you want to run finally after all promises, even if some reject
  5. Be explicit about fire-and-forget operations with comments

Conclusion

The behavior of finally() with nested promises is a direct consequence of how promise chains work. Understanding that promise chains only wait for returned promises is crucial for writing correct asynchronous code.

Golden Rule: If you want an outer promise to wait for an inner promise, you must return it.


Further Reading:

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.