Synchrony and Asynchrony in JavaScript: Callbacks & Promises

Understand synchrony and asynchrony in JavaScript with clear explanations and practical examples. Learn how callbacks, promises, and async/await work to handle asynchronous operations effectively.

Published on 05 March 2026
Reading Time 5
Number of Words 1013

Synchrony and Asynchrony in JavaScript: Callbacks & Promises

JavaScript operates mainly on a single thread, which means it can execute only one piece of code at a time.

To handle tasks like I/O, network requests, timers, or events without blocking the main flow, it uses asynchrony.

Understanding how synchrony and asynchrony work, and how to work with callbacks, promises, and async/await—is essential in writing responsive, nonblocking code.


Table of Contents

  1. Synchronous vs Asynchronous: What’s the Difference?
  2. Callback Functions
  3. Problems with Callbacks: “Callback Hell”
  4. Promises: Clean Asynchronous Control Flow
  5. Chaining Promises
  6. Error Handling in Promises
  7. async / await Syntax
  8. Real-World Examples
  9. Best Practices & Tips


1. Synchronous vs Asynchronous: What’s the Difference?

  • Synchronous (blocking): Code is executed line by line. Each operation must complete before the next starts.
  • Asynchronous (non-blocking): Some operations can start and continue in the background while later code runs; when done, they “notify” via callbacks, promises, or events.

Example:

console.log('Start');

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

console.log('End');

Output:

Start
End
Asynchronous callback

Even though setTimeout is called before console.log('End'), its callback happens later—this is asynchrony at work.


2. Callback Functions

A callback is a function passed as an argument to another function, to be executed later (when some operation completes).

function fetchData(callback) {
  setTimeout(() => {
    const data = { name: 'Alice', age: 25 };
    callback(data);
  }, 1000);
}

console.log('Fetching...');
fetchData((result) => {
  console.log('Data received:', result);
});
console.log('This may run before data arrives');

While callbacks are simple and straightforward, they have drawbacks:

  • Nesting many operations leads to deeply nested code (callback hell)
  • Hard to handle errors and coordinate multiple asynchronous tasks


3. Problems with Callbacks: “Callback Hell”

When you have multiple sequential asynchronous operations, callbacks can become nested like this:

doStep1((res1) => {
  doStep2(res1, (res2) => {
    doStep3(res2, (res3) => {
      doStep4(res3, (res4) => {
        console.log('Final result', res4);
      });
    });
  });
});

This is hard to read, maintain, and handle errors properly. That’s where promises come in.


4. Promises: Clean Asynchronous Control Flow

A Promise is an object representing the eventual result (or failure) of an asynchronous operation. It has three states:

  • pending (initial state)
  • fulfilled (operation succeeded)
  • rejected (operation failed)

You create a promise using the Promise constructor:

function fetchData() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const data = { name: 'Alice', age: 25 };
      // Suppose success condition:
      if (data) {
        resolve(data);
      } else {
        reject(new Error('No data found'));
      }
    }, 1000);
  });
}

// Usage
fetchData()
  .then((result) => {
    console.log('Data:', result);
  })
  .catch((error) => {
    console.error('Error:', error);
  });

With promises, the code becomes flatter and easier to reason about.


5. Chaining Promises

Promises support chaining, so you can sequence asynchronous operations without deep nesting:

fetchData()
  .then((data) => {
    console.log('First fetch:', data);
    return fetchDataFromAnotherSource(data);
  })
  .then((moreData) => {
    console.log('Second fetch:', moreData);
    return doAnotherAsync(moreData);
  })
  .then((final) => {
    console.log('Final:', final);
  })
  .catch((error) => {
    console.error('Error in chain:', error);
  });

Each .then returns a new promise, so you can keep chaining.


6. Error Handling in Promises

Error handling is more consistent with promises:

  • Use .catch(...) at the end of a chain to handle any rejection in the chain
  • You can also catch in the middle:

fetchData()
  .then((data) => {
    return doSomethingAsync(data);
  })
  .catch((err) => {
    console.error('Error during doSomething:', err);
    // Optionally rethrow or recover
  })
  .then((next) => {
    return anotherAsync(next);
  })
  .catch((err) => {
    console.error('Error later:', err);
  });

If a .then callback throws an error or returns a rejected promise, it’s caught in the next .catch.


7. async / await Syntax

async / await is syntactic sugar over promises that lets you write asynchronous code as though it were synchronous, improving readability.

async function main() {
  try {
    const data = await fetchData();
    console.log('Received:', data);
    const more = await fetchMore(data);
    console.log('More:', more);
    const final = await anotherAsync(more);
    console.log('Final:', final);
  } catch (error) {
    console.error('Error in async function:', error);
  }
}

main();

Inside an async function:

  • await pauses execution until the promise resolves or rejects
  • If rejected, an exception is thrown and can be caught with try/catch

You can return values (they are wrapped in a promise) or throw errors inside async functions.


8. Real-World Examples

Example 1: Fetching Data from an API

async function getUserData(userId) {
  const response = await fetch(`https://api.example.com/users/${userId}`);
  if (!response.ok) {
    throw new Error('Network response was not OK');
  }
  const data = await response.json();
  return data;
}

getUserData(123)
  .then((user) => {
    console.log('User data:', user);
  })
  .catch((err) => {
    console.error('Failed to fetch user:', err);
  });

Example 2: Run Multiple Promises in Parallel

async function loadAll() {
  const [posts, comments, profile] = await Promise.all([
    fetch('/posts').then(res => res.json()),
    fetch('/comments').then(res => res.json()),
    fetch('/profile').then(res => res.json())
  ]);

  console.log({ posts, comments, profile });
}

loadAll().catch(err => console.error(err));

Here, Promise.all waits for all the promises to resolve (or rejects if any fails).

Example 3: Sequential and Conditional Async Operations

async function process(id) {
  const user = await getUserData(id);
  if (!user.isActive) {
    throw new Error('User is inactive');
  }
  const content = await fetchContent(user.pref);
  return content;
}

process(42)
  .then((content) => console.log('Content:', content))
  .catch((err) => console.error('Error:', err));


9. Best Practices & Tips

  • Prefer promises or async/await over raw callbacks for better readability and maintainability
  • Always handle errors—either .catch or try/catch in async functions
  • Use Promise.all, Promise.race, Promise.allSettled or Promise.any to manage multiple concurrent promises
  • If you have asynchronous loops, use for … of with await, not Array.forEach
  • Be cautious with long chains; break into helper functions when needed
  • Consider cancellation (AbortController) for fetch or long-running operations
  • Don’t mix callback-style and promise style within the same function — be consistent