Async functions javascript

Async Functions Javascript A Simple Guide To Non-Blocking Code

By Zest | | 19 min read
Async Functions Javascript A Simple Guide To Non-Blocking Code

Async functions in JavaScript are a game-changer for handling operations that take time, like fetching data from an API. They let you write code that waits for things to finish without freezing up everything else, and they do it in a way that’s clean and easy to read—almost like you’re writing regular, synchronous code.

The Journey to Modern Asynchronous JavaScript

To really get why async functions are so great, you have to look back at the problems they were designed to solve. At its core, JavaScript is single-threaded, which means it can only do one thing at a time. Think of a kitchen with just one chef. If that chef has to stop everything to wait for water to boil, nothing else gets cooked. The entire kitchen grinds to a halt.

This was a huge headache for early JavaScript developers. If the code had to wait for a slow network request, the entire user interface would freeze. The solution was to use callbacks. A callback is just a function you pass to another function, telling it, “Hey, when you’re done with your task, run this code for me.” It worked, but it often created a tangled mess of nested functions known as “callback hell,” which was a nightmare to read and debug.

The Rise of Promises

To clean up that mess, ES6 (the 2015 update to JavaScript) introduced Promises. A Promise is an object that represents a future result—either a successful value or an error. It’s like getting a ticket at a coffee shop. That ticket (the Promise) is your guarantee that you’ll eventually get either your coffee (a resolved value) or be told they’re out of espresso (a rejected error). This allowed developers to chain actions together with .then() for successes and .catch() for failures, making the code much more organized.

Promises were a huge step up. They turned the chaotic, nested pyramid of callbacks into a clean, linear chain of events that was much easier to follow.

The Arrival of Async and Await

While Promises made things better, the real breakthrough came with async functions in JavaScript, which landed in ECMAScript 2017 (ES8). Async/await is basically a cleaner, more intuitive way to work with Promises. It lets you write asynchronous code that reads like a simple, step-by-step recipe, completely hiding the .then() chains. An async function always returns a Promise, and the await keyword simply tells the function to pause and wait for that Promise to settle before moving on.

This modern style took off immediately. One study analyzed 4,221 real-world code migrations from Promises to async/await across 134 popular GitHub projects. The most telling statistic? 87% of these changes were 10 lines of code or fewer, proving just how easy it is for teams to adopt this syntax for a massive gain in readability.

Before we dive deeper, it’s a good idea to have a solid grasp of other essential JavaScript concepts that underpin modern development. Understanding this evolution from callbacks to Promises to async/await is key—it shows how the language has continually improved to make one of its trickiest parts more intuitive for developers.

Understanding Async/Await And Promises

To really get a handle on async functions in JavaScript, you first have to understand what they’re built on: Promises. A Promise is just what it sounds like—a guarantee from JavaScript that you’ll get a value eventually. It’s a placeholder for the result of an operation that takes time, like a network request. That operation will either succeed (resolve) or fail (reject), and the Promise object holds its fate.

An async function is essentially a neat wrapper that automatically returns a Promise. The real star of the show, though, is the await keyword. When you await a Promise inside an async function, you’re telling JavaScript to pause right there—without freezing the whole browser—and wait for that Promise to settle. Once the value is ready, your function picks up right where it left off.

It’s a simple but powerful way to write asynchronous code that looks and feels synchronous, making it far easier to read and debug.

From Promise Chains To Clean Code

Let’s look at a practical example. Say you need to fetch some user data from an API. The old-school way, using pure Promises, involves chaining .then() methods.

// Using Promises with .then() function getUserData() { fetch(‘https://api.example.com/user/1’) .then(response => { if (!response.ok) { throw new Error(‘Network response was not ok’); } return response.json(); }) .then(data => { console.log(‘User data:’, data); }) .catch(error => { console.error(‘Fetch error:’, error); }); } It works, but it can get messy fast. This “pyramid of doom” or “callback hell” was a common headache for years.

Now, here’s the same exact logic rewritten with async/await. The difference is night and day.

// The same logic with async/await async function getUserDataAsync() { try { const response = await fetch(‘https://api.example.com/user/1’); if (!response.ok) { throw new Error(‘Network response was not ok’); } const data = await response.json(); console.log(‘User data:’, data); } catch (error) { console.error(‘Fetch error:’, error); } } The code reads like a simple, step-by-step recipe. This incredible boost in readability is why async/await is now the go-to for modern JavaScript development.

This evolution from confusing callbacks to the clean, linear flow of async/await didn’t happen overnight. It was a direct response to developers’ needs for more manageable code.

Diagram illustrating the evolution of asynchronous JavaScript: Callbacks, Promises, and Async/Await methods.

As the diagram shows, each new pattern was a major step toward making asynchronous logic feel more intuitive and less error-prone.

To better understand these differences, let’s compare the core characteristics of each approach.

Comparing Asynchronous JavaScript Patterns

Pattern Syntax Style Error Handling Readability
Callbacks Nested functions Error-first argument ((err, data) => ...) Low (Callback Hell)
Promises Chained .then() and .catch() .catch() block at the end of the chain Medium (Flatter, but still chained)
Async/Await Synchronous-style try...catch Standard try...catch blocks High (Looks like synchronous code)

This table makes it clear why async/await won. It gives us the power of non-blocking code with the readability and familiar error-handling patterns of synchronous programming.

How JavaScript Juggles Tasks

So, if await pauses a function, how does the rest of the application keep running? The secret lies in JavaScript’s event loop and a special list called the microtask queue.

Think of the event loop as a tireless manager constantly checking its to-do lists. When an async function hits an await, the manager takes the rest of that function, puts it on a high-priority “microtask” list, and then gets back to other work on its main list (like rendering UI updates or responding to clicks).

As soon as the main JavaScript call stack is empty, the event loop immediately turns its attention to the microtask queue. It processes every task on that list before doing anything else. This ensures that your awaited Promises are handled as quickly as possible, creating a responsive and efficient system for managing concurrency without freezing the user interface.

7. Effective Error Handling With Try And Catch

One of the biggest wins you get with async functions in JavaScript is how they clean up error handling. If you’ve ever found yourself tangled in long .catch() chains with Promises, you’ll appreciate this. We can go back to using the familiar try...catch block, a pattern most of us have used for synchronous code for decades.

It’s a simple but powerful shift. When a Promise you await is rejected, it behaves just like a traditional function that throws an error. This lets you catch it in a clean, readable catch block, avoiding the nested logic that often made old-school Promise error handling a headache.

Cleaner Than Chaining Catches

With plain Promises, handling failures across several asynchronous steps can get clunky. You might end up with a .catch() for each step or a complicated single one trying to figure out what went wrong where.

async/await completely changes the game. You can stack multiple await calls inside a single try block, and if any one of them fails, execution immediately jumps to the catch block. This is huge.

A single try...catch block can wrap an entire sequence of asynchronous operations, making your error-handling logic simpler, more centralized, and easier to debug.

For instance, let’s say you need to fetch a user’s profile and then their recent posts.

async function fetchUserAndPosts(userId) { try { const userResponse = await fetch(/api/users/${userId}); if (!userResponse.ok) throw new Error(‘Failed to fetch user’); const user = await userResponse.json();

const postsResponse = await fetch(`/api/posts?userId=${user.id}`);
if (!postsResponse.ok) throw new Error('Failed to fetch posts');
const posts = await postsResponse.json();

console.log('Successfully fetched:', { user, posts });

} catch (error) { console.error(‘An error occurred:’, error.message); // You could show an error message to the user here } }

The code reads like a simple, step-by-step story. If the user fetch fails, or if the post fetch fails, control hops directly to the catch block. This predictable flow is a cornerstone of writing solid, reliable code. Of course, a big part of writing robust code is knowing how to fix it when it breaks, which is why it’s worth getting comfortable with tools for debugging with Visual Studio Code.

Handling Multiple Simultaneous Requests

So, what happens when you fire off multiple requests at once with Promise.all? The try...catch pattern handles this beautifully, too. Remember, Promise.all will reject the moment any one of the Promises inside it rejects.

By wrapping your Promise.all call in a try...catch, you can gracefully handle a failure in any single request out of the entire batch. This stops one bad API call from taking down the whole operation and lets you log the specific error or show a friendly message to the user, making your application far more resilient.

Optimizing Performance With Concurrency Patterns

Knowing how to write async functions is just the first step. The real art is writing fast async code. If you just slap await in front of every asynchronous call, you might be creating a performance bottleneck without even realizing it. You’re forcing tasks to run one by one when they could easily be running all at once.

Think of it like a single-lane road. When you await promises one after another, each car has to complete its entire journey before the next one can even start. For independent tasks, this is a huge, unnecessary traffic jam.

A laptop screen displays 'PARALLEL REQUESTS' with a bar chart illustrating performance optimization.

Sequential vs. Parallel With Promise.all

Let’s imagine you’re building a user dashboard. You need to pull in the user’s profile data, their latest notifications, and their account settings. If you use sequential await calls, the total load time is the sum of all three network requests.

async function loadDashboardSequentially() { console.time(‘sequential’);

// Each await pauses execution until the promise resolves const userData = await fetch(‘/api/user’); // Waits ~500ms const notifications = await fetch(‘/api/notifications’); // Waits another ~300ms const settings = await fetch(‘/api/settings’); // Waits another ~400ms

console.timeEnd(‘sequential’); // Total: ~1200ms }

This is painfully slow. There’s no reason to wait for user data to finish loading before you start fetching notifications. They’re completely independent. This is the perfect job for Promise.all, which turns our single-lane road into a multi-lane highway.

Promise.all takes an array of promises and gives you back a single promise. This new promise resolves only when every single one of the input promises has finished. The best part? The total time you wait is only as long as the slowest promise in the group, not the sum of them all.

async function loadDashboardInParallel() { console.time(‘parallel’);

// Start all the fetch requests at the same time const promises = [ fetch(‘/api/user’), fetch(‘/api/notifications’), fetch(‘/api/settings’) ];

// await a single promise that waits for them all to finish const [userResponse, notificationsResponse, settingsResponse] = await Promise.all(promises);

console.timeEnd(‘parallel’); // Total: ~500ms (the time of the longest request) } By running these requests in parallel, you can slash loading times and deliver a much snappier, more professional user experience.

Handling All Outcomes With Promise.allSettled

There’s one big catch with Promise.all: it has an all-or-nothing personality. If even one promise in your array fails (rejects), the entire Promise.all call will immediately fail. Sometimes, that’s not what you want. You might need to know the result of every single operation, even the ones that went wrong.

That’s exactly what Promise.allSettled is for. It waits for all promises to finish, whether they succeeded or failed. Then, it returns an array of objects, where each object tells you the final status ('fulfilled' or 'rejected') of its corresponding promise.

Use Promise.all when all tasks are critical and must succeed. Use Promise.allSettled when you need to know the results of all tasks, even if some of them failed.

Winning the Race With Promise.race

Finally, we have Promise.race. This one also takes an array of promises, but it doesn’t wait for all of them. Instead, it resolves or rejects as soon as the very first promise in the array settles. It’s a race, and you only care about the winner.

This is incredibly useful when you have multiple sources for the same data and just want the fastest response. For example, you could ping several redundant API endpoints and use whichever one replies first.

This shift to thinking in parallel has reshaped modern web development. By 2025, async programming was at the core of major JavaScript frameworks, with libraries like React using these exact concurrency patterns to juggle UI updates and background data fetching. As countless teams have discovered, poor async handling can spike session “stuck time” by 40%. You can dive deeper into how modern frameworks are built on these principles and other emerging trends in the JS ecosystem heading into 2026.

Mastering these concurrency patterns isn’t just a nice-to-have skill anymore—it’s absolutely essential for building high-performance, modern applications.

Common Async Pitfalls And How To Avoid Them

While async/await makes asynchronous JavaScript feel much simpler, that friendly syntax can hide some tricky new gotchas. If you want to go from just using async functions in JavaScript to truly mastering them, you need to know where the common traps are. Many of these issues are subtle; they won’t throw an error right away but can introduce flaky behavior that haunts you later.

A cluttered desk with a laptop, tangled black cables, sticky notes, and a banner reading "Avoid Pitfalls".

It’s no surprise this syntax is a developer favorite. Yet, its simplicity can be deceiving. I’ve seen firsthand how the await keyword, while clean, can hide the complex reality of how tasks are actually executed. In fact, one analysis showed that over 60% of developers run into race conditions in their apps, often because they’ve misunderstood this.

Forgetting The Await Keyword

This is, without a doubt, the most common mistake. You call an async function, but you forget to put await in front of it. Here’s the problem: every async function returns a Promise. Without await, your code doesn’t pause—it just gets handed a pending Promise object and immediately moves on to the next line.

This mistake is a classic recipe for race conditions. Your code might look like it’s working, but it’s really operating on data that hasn’t arrived yet. This leads to some of the most frustrating and difficult-to-trace bugs you’ll ever encounter.

Using Await Inside forEach Loops

Another classic trap is trying to use await inside a forEach loop. It just won’t behave the way you think it will. forEach is not “Promise-aware,” meaning it fires off its callback for every single item in the array without waiting. The await inside only pauses that one little callback function, not the loop itself.

When you need to run async tasks one after another, a simple for...of loop is the right tool for the job.

  • Incorrect (forEach): The loop will be long gone before any of your async operations have a chance to finish.
  • Correct (for...of): The loop actually pauses at each await, ensuring one task finishes completely before the next one starts.

Of course, mastering async logic goes hand-in-hand with other fundamentals, like having a solid grasp of JavaScript type checking.

Unhandled Promise Rejections

This is probably the most dangerous pitfall of all. If an async function throws an error and you haven’t wrapped your await call in a try...catch block (or chained a .catch()), that error can just vanish. In some browsers, it fails silently. In Node.js, it can—and often will—crash your entire server.

When you’re working in a Node environment, this becomes even more critical. It’s one of the core ideas behind the best practices for Node.js development. Always, always make sure every async call has a way to handle failures.

Testing And Debugging Your Async Functions

Getting your asynchronous code to work is just the first step. The real challenge is making sure it works reliably every single time. This is where solid testing and debugging come in, especially when you’re dealing with the unique, non-linear flow of async functions in JavaScript.

The good news is that modern testing frameworks like Jest are built from the ground up to handle asynchronous code. The trick is to signal to your test runner that it needs to wait. Jest gives you a few ways to do this, but simply returning the Promise from your test function is often all you need to ensure the test doesn’t end before your async function has done its job.

Writing Reliable Async Tests

We’ve all been there: a test passes, you run it again, and suddenly it fails. These “flaky tests” are a common headache, and they often pop up when a test for an async function doesn’t correctly wait for the operation to finish.

The cleanest way to solve this is to use async/await right inside your test functions.

// A simple test using async/await in Jest test(‘should fetch user data correctly’, async () => { const data = await fetchUser(1); expect(data.id).toBe(1); });

This syntax is incredibly clear and easy to read. It tells the test exactly what to do: wait for fetchUser to resolve, and then check the result. If you’re looking for more ways to build bulletproof tests, our guide on unit testing best practices has some great strategies.

Mocking and Debugging Techniques

You definitely don’t want your tests hitting a real API every time you run them. It’s slow, depends on a network connection, and can even cost you money if the API is a paid service. The professional approach is mocking.

Tools like Jest let you swap out a real function (like fetch) with a “fake” one that you control. This fake function can return whatever you want, allowing you to test how your code handles both successful responses and error states without any external dependencies. It’s all about isolating your logic.

When you’re trying to figure out why something is breaking, the debugger keyword is an absolute lifesaver. Just drop it into your async function, and it will automatically create a breakpoint.

When your code hits that line, it will pause execution and pop open your browser’s developer tools. From there, you can step through your code line by line, inspect the values of variables, and get a clear picture of how your asynchronous logic is unfolding. It’s like having x-ray vision for your code.

Frequently Asked Questions About Async JavaScript

As you start using async/await more, you’ll probably run into the same questions that trip up most developers. Let’s clear up a few of those common sticking points. Getting these concepts right is the key to writing async code that’s clean, fast, and bug-free.

Can I Use Async/Await Without Promises?

Nope, you can’t. It’s a common misconception, but async/await isn’t a replacement for Promises—it’s just a much cleaner way to write them.

Think of it as syntactic sugar. Under the hood, an async function always returns a Promise. The await keyword is designed to do one thing: pause the function until that Promise settles. Without a Promise to “await,” the syntax has nothing to work with.

What’s the Difference Between Promise.all and Promise.allSettled?

The main difference is how they deal with failure.

Promise.all is an all-or-nothing deal. If you give it five promises and even one of them rejects, the whole thing rejects immediately. You won’t get the results from any of the promises that actually succeeded.

Promise.allSettled, on the other hand, is more forgiving. It waits for all the promises to finish, no matter if they succeed or fail. It then gives you back an array of objects, where each one tells you if its promise was 'fulfilled' or 'rejected', along with the value or reason.

So, when should you use which? Use Promise.all when you have a group of tasks that must all succeed for your code to proceed. If you just need to run a bunch of independent tasks and want to see how each one did, Promise.allSettled is your best bet.

Why Can I Only Use Await Inside an Async Function?

The async keyword is what signals to the JavaScript engine that it needs to manage the function differently. It tells the engine, “Hey, there might be an await in here, so be ready to pause execution and come back later.” Without that async wrapper, the engine wouldn’t know how to handle the pause.

For a long time, this was a strict rule. But things have changed. Most modern JavaScript environments now support top-level await. This lets you use await directly in a module, outside of an async function. It’s incredibly handy for things you need to do right when your app starts, like fetching initial configuration data or connecting to a database.


Async JavaScript Scorecard

Use this scorecard to turn common async pitfalls into expert-level habits for faster, more reliable code.

📊 Core Metrics

  1. Performance Gain — Time saved by converting sequential `await`s to `Promise.all`.
  2. Error Coverage — Percentage of async calls safely wrapped in `try...catch` blocks.
  3. Readability Lift — Subjective improvement moving from `.then()` chains to `async/await`.

🕹️ My Cheatcodes

  • 🌱 Parallelize First — For independent tasks, always reach for `Promise.all` to prevent bottlenecks.
  • 🦎 Loop with `for...of` — The only reliable way to `await` inside a loop sequentially. Avoid `forEach`.
  • ⚡ Catch Everything — Assume any awaited promise can fail. Wrap it in `try...catch` to prevent silent errors or crashes.

📈 Reflection Points

  • 💡 Am I creating an unnecessary sequence of `await` calls that could run in parallel?
  • 🧩 Does every async operation have a defined failure path, or could a rejection go unhandled?
  • 📘 Have I considered if `Promise.allSettled` is a better fit than `Promise.all` for this use case?

Gallery

Carousel image
Carousel image
Carousel image
Carousel image
Carousel image
Carousel image
Carousel image
Carousel image

FAQ

Frequently asked questions

Haven't found what you're looking for?

Connecting your GitHub org gives you team-level AI adoption signals out of the box — PR throughput, review patterns, and Acceleration Reports — without anyone installing anything else.

No. GitHub alone unlocks the team dashboard. For richer per-engineer insights (AI Stack, AI Skills, agent activity), engineers can install Zest in Cursor or Claude Code at any time.

Zest tracks blockers across tasks and prompts. By spotting weak spots and highlighting working strategies, your team keeps momentum and ships code faster.

Copilot metrics tell you usage; Zest tells you outcomes. We measure AI compute impact on PR throughput, code review quality, and agent autonomy levels — across Cursor, Claude Code, Codex, and any model your team runs.

Adoption charts show who's using AI, how often, and where value is gained. Teams and enterprises can drive toward 100% usage — ensuring everyone benefits.

Yes. Connect your GitHub org and you'll get the L2 dashboard — leaderboards, AI metrics, and team insights — with no credit card needed.