Skip to main content

Front-End Development

Promises Made Simple: Understanding Async/Await in JavaScript

Istock 2163867912

JavaScript is single-threaded. That means it runs one task at a time, on one core. But then how does it handle things like API calls, file reads, or user interactions without freezing up?

That’s where Promises and async/await come into play. They help us handle asynchronous operations without blocking the main thread.

Let’s break down these concepts in the simplest way possible so whether you’re a beginner or a seasoned dev, it just clicks.

JavaScript has something called an event loop. It’s always running, checking if there’s work to do—like handling user clicks, network responses, or timers. In the browser, the browser runs it. In Node.js, Node takes care of it.

When an async function runs and hits an await, it pauses that function. It doesn’t block everything—other code keeps running. When the awaited Promise settles, that async function picks up where it left off.

 

What is a Promise?

  • Fulfilled – The operation completed successfully.
  • Rejected – Something went wrong.
  • Pending – Still waiting for the result.

Instead of using nested callbacks (aka “callback hell”), Promises allow cleaner, more manageable code using chaining.

 Example:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
fetchData()
.then(data =>process(data))
.then(result =>console.log(result))
.catch(error =>console.error(error));
fetchData() .then(data => process(data)) .then(result => console.log(result)) .catch(error => console.error(error));
fetchData()
  .then(data => process(data))
  .then(result => console.log(result))
  .catch(error => console.error(error));

 

Common Promise Methods

Let’s look at the essential Promise utility methods:

  1. Promise.all()

Waits for all promises to resolve. If any promise fails, the whole thing fails.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Promise.all([p1, p2, p3])
.then(results =>console.log(results))
.catch(error =>console.error(error));
Promise.all([p1, p2, p3]) .then(results => console.log(results)) .catch(error => console.error(error));
Promise.all([p1, p2, p3])
  .then(results => console.log(results))
  .catch(error => console.error(error));
  • ✅ Resolves when all succeed.
  • ❌ Rejects fast if any fail.
  1. Promise.allSettled()

Waits for all promises, regardless of success or failure.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Promise.allSettled([p1, p2, p3])
.then(results =>console.log(results));
Promise.allSettled([p1, p2, p3]) .then(results => console.log(results));
Promise.allSettled([p1, p2, p3])
  .then(results => console.log(results));
  • Each result shows { status: “fulfilled”, value } or { status: “rejected”, reason }.
  • Great when you want all results, even the failed ones.
  1. Promise.race()

Returns as soon as one promise settles (either resolves or rejects).

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Promise.race([p1, p2, p3])
.then(result =>console.log('Fastest:', result))
.catch(error =>console.error('First to fail:', error));
Promise.race([p1, p2, p3]) .then(result => console.log('Fastest:', result)) .catch(error => console.error('First to fail:', error));
Promise.race([p1, p2, p3])
  .then(result => console.log('Fastest:', result))
  .catch(error => console.error('First to fail:', error));
  1. Promise.any()

Returns the first fulfilled promise. Ignores rejections unless all fail.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
Promise.any([p1, p2, p3])
.then(result =>console.log('First success:', result))
.catch(error =>console.error('All failed:', error));
Promise.any([p1, p2, p3]) .then(result => console.log('First success:', result)) .catch(error => console.error('All failed:', error));
Promise.any([p1, p2, p3])
  .then(result => console.log('First success:', result))
  .catch(error => console.error('All failed:', error));

5.Promise.resolve() / Promise.reject

  • resolve(value) creates a resolved promise.
  • reject (value) creates a rejected promise.

Used for quick returns or mocking async behavior.

 

Why Not Just Use Callbacks?

Before Promises, developers relied on callbacks:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
getData(function(response){
process(response, function(result){
finalize(result);
});
});
getData(function(response) { process(response, function(result) { finalize(result); }); });
getData(function(response) {
  process(response, function(result) {
    finalize(result);
  });
});

This worked, but quickly became messy i.e. callback hell.

 

 What is async/await Really Doing?

Under the hood, async/await is just syntactic sugar over Promises. It makes asynchronous code look synchronous, improving readability and debuggability.

How it works:

  • When you declare a function with async, it always returns a Promise.
  • When you use await inside an async function, the execution of that function pauses at that point.
  • It waits until the Promise is either resolved or rejected.
  • Once resolved, it returns the value.
  • If rejected, it throws the error, which you can catch using try…catch.
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
asyncfunctiongreet(){
return'Hello';
}
greet().then(msg =>console.log(msg)); // Hello
async function greet() { return 'Hello'; } greet().then(msg => console.log(msg)); // Hello
async function greet() {
  return 'Hello';
}
greet().then(msg => console.log(msg)); // Hello

Even though you didn’t explicitly return a Promise, greet() returns one.

 

Execution Flow: Synchronous vs Async/Await

Let’s understand how await interacts with the JavaScript event loop.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
console.log("1");
setTimeout(()=>console.log("2"), 0);
(asyncfunction(){
console.log("3");
await Promise.resolve();
console.log("4");
})();
console.log("5");
console.log("1"); setTimeout(() => console.log("2"), 0); (async function() { console.log("3"); await Promise.resolve(); console.log("4"); })(); console.log("5");
console.log("1");

setTimeout(() => console.log("2"), 0);

(async function() {
  console.log("3");
  await Promise.resolve();
  console.log("4");
})();

console.log("5");

Output:

Let’s understand how await interacts with the JavaScript event loop.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
1
3
5
4
2
1 3 5 4 2
1
3
5
4
2

Explanation:

  • The await doesn’t block the main thread.
  • It puts the rest of the async function in the microtask queue, which runs after the current stack and before setTimeout (macrotask).
  • That’s why “4” comes after “5”.

 

 Best Practices with async/await

  1. Use try/catch for Error Handling

Avoid unhandled promise rejections by always wrapping await logic inside a try/catch.

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
asyncfunctiongetUser(){
try{
const res = awaitfetch('/api/user');
if(!res.ok)thrownewError('User not found');
const data = await res.json();
return data;
}catch(error){
console.error('Error fetching user:', error.message);
throw error; // rethrow if needed
}
}
async function getUser() { try { const res = await fetch('/api/user'); if (!res.ok) throw new Error('User not found'); const data = await res.json(); return data; } catch (error) { console.error('Error fetching user:', error.message); throw error; // rethrow if needed } }
async function getUser() {
  try {
    const res = await fetch('/api/user');
    if (!res.ok) throw new Error('User not found');
    const data = await res.json();
    return data;
  } catch (error) {
    console.error('Error fetching user:', error.message);
    throw error; // rethrow if needed
  }
}
  1. Run Parallel Requests with Promise.all

Don’t await sequentially unless there’s a dependency between the calls.

❌ Bad:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const user = awaitgetUser();
const posts = awaitgetPosts(); // waits for user even if not needed
const user = await getUser(); const posts = await getPosts(); // waits for user even if not needed
const user = await getUser();
const posts = await getPosts(); // waits for user even if not needed

✅ Better:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const[user, posts] = await Promise.all([getUser(), getPosts()]);
const [user, posts] = await Promise.all([getUser(), getPosts()]);
const [user, posts] = await Promise.all([getUser(), getPosts()]);
  1. Avoid await in Loops (when possible)

❌ Bad:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
//Each iteration waits for the previous one to complete
for(let user of users){
awaitsendEmail(user);
}
//Each iteration waits for the previous one to complete for (let user of users) { await sendEmail(user); }
//Each iteration waits for the previous one to complete
for (let user of users) {
  await sendEmail(user);
}

✅ Better:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
//Run in parallel
await Promise.all(users.map(user =>sendEmail(user)));
//Run in parallel await Promise.all(users.map(user => sendEmail(user)));
//Run in parallel
await Promise.all(users.map(user => sendEmail(user)));

Common Mistakes

  1. Using await outside async
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const data = awaitfetch(url); // ❌ SyntaxError
const data = await fetch(url); // ❌ SyntaxError
const data = await fetch(url); // ❌ SyntaxError
  1. Forgetting to handle rejections
    If your async function throws and you don’t .catch() it (or use try/catch), your app may crash in Node or log warnings in the browser.
  2. Blocking unnecessary operations Don’t await things that don’t need to be awaited. Only await when the next step depends on the result.

 

Real-World Example: Chained Async Workflow

Imagine a system where:

  • You authenticate a user,
  • Then fetch their profile,
  • Then load related dashboard data.

Using async/await:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
asyncfunctioninitDashboard(){
try{
const token = awaitlogin(username, password);
const profile = awaitfetchProfile(token);
const dashboard = awaitfetchDashboard(profile.id);
renderDashboard(dashboard);
}catch(err){
console.error('Error loading dashboard:', err);
showErrorScreen();
}
}
async function initDashboard() { try { const token = await login(username, password); const profile = await fetchProfile(token); const dashboard = await fetchDashboard(profile.id); renderDashboard(dashboard); } catch (err) { console.error('Error loading dashboard:', err); showErrorScreen(); } }
async function initDashboard() {
  try {
    const token = await login(username, password);
    const profile = await fetchProfile(token);
    const dashboard = await fetchDashboard(profile.id);
    renderDashboard(dashboard);
  } catch (err) {
    console.error('Error loading dashboard:', err);
    showErrorScreen();
  }
}

Much easier to follow than chained .then() calls, right?

 

Converting Promise Chains to Async/Await

Old way:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
login()
.then(token =>fetchUser(token))
.then(user =>showProfile(user))
.catch(error =>showError(error));
login() .then(token => fetchUser(token)) .then(user => showProfile(user)) .catch(error => showError(error));
login()
  .then(token => fetchUser(token))
  .then(user => showProfile(user))
  .catch(error => showError(error));

With async/await:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
asyncfunctionstart(){
try{
const token = awaitlogin();
const user = awaitfetchUser(token);
showProfile(user);
}catch(error){
showError(error);
}
}
async function start() { try { const token = await login(); const user = await fetchUser(token); showProfile(user); } catch (error) { showError(error); } }
async function start() {
  try {
    const token = await login();
    const user = await fetchUser(token);
    showProfile(user);
  } catch (error) {
    showError(error);
  }
}

Cleaner. Clearer. Less nested. Easier to debug.

 

Bonus utility wrapper for Error Handling

If you hate repeating try/catch, use a helper:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const to = promise => promise.then(res =>[null, res]).catch(err =>[err]);
asyncfunctionloadData(){
const[err, data] = awaitto(fetchData());
if(err)returnconsole.error(err);
console.log(data);
}
const to = promise => promise.then(res => [null, res]).catch(err => [err]); async function loadData() { const [err, data] = await to(fetchData()); if (err) return console.error(err); console.log(data); }
const to = promise => promise.then(res => [null, res]).catch(err => [err]);

async function loadData() {
  const [err, data] = await to(fetchData());
  if (err) return console.error(err);
  console.log(data);
}

 

Final Thoughts

Both Promises and async/await are powerful tools for handling asynchronous code. Promises came first and are still widely used, especially in libraries. async/awa is now the preferred style in most modern JavaScript apps because it makes the code cleaner and easier to understand.

 

Tip: You don’t have to choose one forever — they work together! In fact, async/await is built on top of Promises.

 

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.

Apeksha Handore

Apeksha is a front-end developer with expertise in React.js and Next.js. She crafts performant, scalable, and accessible web applications. With a strong background in modern UI development and state management, she specializes in building intuitive user experiences and robust front-end architectures.

More from this Author

Follow Us