Editor’s note: This article was last reviewed and updated by Ikeh Akinyemi in January 2025 to introduce advanced techniques for working with async/await, such as handling multiple async operations concurrently using Promise.all and managing async iterations with for await...of, as well as how to apply async/await within higher-order functions.
Asynchronous programming is a way of writing code that can carry out tasks independently of each other, not needing one task to be completed before another gets started. When you think of asynchronous programming, think of multitasking and effective time management.
If you’re reading this, you probably have some familiarity with asynchronous programming in JavaScript, and you may be wondering how it works in TypeScript. That’s what we’ll explore in this guide.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Before diving into async/await, it’s important to mention that promises form the foundation of asynchronous programming in JavaScript/TypeScript. A promise represents a value that might not be immediately available but will be resolved at some point in the future. A promise can be in one of the following three states:
Here’s how to create and work with promises in TypeScript:
// Type-safe Promise creation
interface ApiResponse {
data: string;
timestamp: number;
}
const fetchData = new Promise<ApiResponse>((resolve, reject) => {
try {
// Simulating API call
setTimeout(() => {
resolve({
data: "Success!",
timestamp: Date.now()
});
}, 1000);
} catch (error) {
reject(error);
}
});
Promises can be chained using .then() for successful operations and .catch() for error handling:
fetchData
.then(response => {
console.log(response.data); // TypeScript knows response has ApiResponse type
return response.timestamp;
})
.then(timestamp => {
console.log(new Date(timestamp).toISOString());
})
.catch(error => {
console.error('Error:', error);
});
We’ll revisit the concept of promises later, where we’ll discuss how to possibly execute asynchronous operations in parallel.
async/await in TypeScriptTypeScript is a superset of JavaScript, so async/await works the same, but with some extra goodies and type safety. TypeScript enables you to ensure type safety for the expected result and even check for type errors, which helps you detect bugs earlier in the development process.
async/await is essentially a syntactic sugar for promises, which is to say that the async/await keyword is a wrapper over promises. An async function always returns a promise. Even if you omit the Promise keyword, the compiler will wrap your function in an immediately resolved promise.
Here’s an example:
//Snippet 1
const myAsynFunction = async (url: string): Promise<T> => {
const { data } = await fetch(url)
return data
}
//Snippet 2
const immediatelyResolvedPromise = (url: string) => {
const resultPromise = new Promise((resolve, reject) => {
resolve(fetch(url))
})
return resultPromise
}
Although they look different, the code snippets above are more or less equivalent.
async/await simply enables you to write the code more synchronously and unwraps the promise within the same line of code for you. This is powerful when you’re dealing with complex asynchronous patterns.
To get the most out of the async/await syntax, you’ll need a basic understanding of promises.
As explained earlier, a promise refers to the expectation that something will happen at a particular time, enabling your app to use the result of that future event to perform certain other tasks.
To demonstrate what I mean, I’ll break down a real-world example and translate it into pseudocode, followed by the actual TypeScript code.
Let’s say I have a lawn to mow. I contact a mowing company that promises to mow my lawn in a couple of hours. In turn, I promise to pay them immediately afterward, provided the lawn is properly mowed.
Can you spot the pattern? The first obvious thing to note is that the second event relies entirely on the previous one. If the first event’s promise is fulfilled, the next event’s will be executed. The promise in that event is then either fulfilled, rejected, or remains pending.
Let’s look at this sequence step by step and then explore its code:

Before we write out the full code, it makes sense to examine the syntax for a promise — specifically, an example of a promise that resolves into a string.
We declared a promise with the new + Promise keyword, which takes in the resolve and reject arguments. Now let’s write a promise for the flow chart above:
// I send a request to the company. This is synchronous
// company replies with a promise
const angelMowersPromise = new Promise<string>((resolve, reject) => {
// a resolved promise after certain hours
setTimeout(() => {
resolve('We finished mowing the lawn')
}, 100000) // resolves after 100,000ms
reject("We couldn't mow the lawn")
})
const myPaymentPromise = new Promise<Record<string, number | string>>((resolve, reject) => {
// a resolved promise with an object of 1000 Euro payment
// and a thank you message
setTimeout(() => {
resolve({
amount: 1000,
note: 'Thank You',
})
}, 100000)
// reject with 0 Euro and an unstatisfatory note
reject({
amount: 0,
note: 'Sorry Lawn was not properly Mowed',
})
})
In the code above, we declared both the company’s promises and our promises. The company promise is either resolved after 100,000ms or rejected. A Promise is always in one of three states: resolved if there is no error, rejected if an error is encountered, or pending if the Promise has been neither rejected nor fulfilled. In our case, it falls within the 100000ms period.
But how can we execute the task sequentially and synchronously? That’s where the then keyword comes in. Without it, the functions simply run in the order they resolve.
thenChaining promises allows them to run in sequence using the then keyword. This functions like a normal human language — do this and then that and then that, and so on.
The code below will run the angelMowersPromise. If there is no error, it’ll run the myPaymentPromise. If there is an error in either of the two promises, it’ll be caught in the catch block:
angelMowersPromise
.then(() => myPaymentPromise.then(res => console.log(res)))
.catch(error => console.log(error))
Now let’s look at a more technical example. A common task in frontend programming is to make network requests and respond to the results accordingly.
Below is a request to fetch a list of employees from a remote server:
const api = 'http://dummy.restapiexample.com/api/v1/employees'
fetch(api)
.then(response => response.json())
.then(employees => employees.forEach(employee => console.log(employee.id)) // logs all employee id
.catch(error => console.log(error.message))) // logs any error from the promise
There may be times when you need numerous promises to execute in parallel or sequence. Constructs such as Promise.all or Promise.race are especially helpful in these scenarios.
For example, imagine that you need to fetch a list of 1,000 GitHub users, and then make an additional request with the ID to fetch avatars for each of them. You don’t necessarily want to wait for each user in the sequence; you just need all the fetched avatars. We’ll examine this in more detail later when we discuss Promise.all.
Now that you have a fundamental grasp of promises, let’s look at the async/await syntax.
async/awaitThe async/await syntax simplifies working with promises in JavaScript. It provides an easy interface to read and write promises in a way that makes them appear synchronous.
An async/await will always return a Promise. Even if you omit the Promise keyword, the compiler will wrap the function in an immediately resolved Promise. This enables you to treat the return value of an async function as a Promise, which is useful when you need to resolve numerous asynchronous functions.
As the name implies, async always goes hand in hand with await. That is, you can only await inside an async function. The async function informs the compiler that this is an asynchronous function.
If we convert the promises from above, the syntax looks like this:
const myAsync = async (): Promise<Record<string, number | string>> => {
await angelMowersPromise
const response = await myPaymentPromise
return response
}
As you can immediately see, this looks more readable and appears synchronous. We told the compiler to await the execution of angelMowersPromise before doing anything else. Then, we return the response from the myPaymentPromise.
You may have noticed that we omitted error handling. We could do this with the catch block after the then in a promise. But what happens if we encounter an error? That leads us to try/catch.
try/catchWe’ll refer to the employee fetching example to see the error handling in action, as it is likely to encounter an error over a network request.
Let’s say, for example, that the server is down, or perhaps we sent a malformed request. We need to pause execution to prevent our program from crashing. The syntax will look like this:
interface Employee {
id: number
employee_name: string
employee_salary: number
employee_age: number
profile_image: string
}
const fetchEmployees = async (): Promise<Array<Employee> | string> => {
const api = 'http://dummy.restapiexample.com/api/v1/employees'
try {
const response = await fetch(api)
const { data } = await response.json()
return data
} catch (error) {
if (error) {
return error.message
}
}
}
We initiated the function as an async function. We expect the return value to be either an array of employees or a string of error messages. Therefore, the type of promise is Promise<Array<Employee> | string>.
Inside the try block are the expressions we expect the function to run if there are no errors. Meanwhile, the catch block captures any errors that arise. In that case, we’d just return the message property of the error object.
The beauty of this is that any error that first occurs within the try block is thrown and caught in the catch block. An uncaught exception can lead to hard-to-debug code or even break the entire program.
While traditional try/catch blocks are effective for catching errors at the local level, they can become repetitive and clutter the main business logic when used too frequently. This is where higher-order functions come into play.
A higher-order function is a function that takes one or more functions as arguments or returns a function. In the context of error handling, a higher-order function can wrap an asynchronous function and handle any errors it might throw, thereby abstracting the try/catch logic away from the core business logic.
The main idea behind using higher-order functions for error handling in async/await is to create a wrapper function that takes an async function as an argument along with any parameters that the async function might need. Inside this wrapper, we implement a try/catch block. This approach allows us to handle errors in a centralized manner, making the code cleaner and more maintainable.
Let’s refer to the employee fetching example:
// Async function to fetch employee data
async function fetchEmployees(apiUrl: string): Promise<Employee[]> {
const response = await fetch(apiUrl);
const data = await response.json();
return data;
}
// Wrapped version of fetchEmployees using the higher-order function
const safeFetchEmployees = (url: string) => handleAsyncErrors(fetchEmployees, url);
// Example API URL
const api = 'http://dummy.restapiexample.com/api/v1/employees';
// Using the wrapped function to fetch employees
safeFetchEmployees(api)
.then(data => {
if (data) {
console.log("Fetched employee data:", data);
} else {
console.log("Failed to fetch employee data.");
}
})
.catch(err => {
// This catch block might be redundant, depending on your error handling strategy within the higher-order function
console.error("Error in safeFetchEmployees:", err);
});
In this example, the safeFetchEmployees function uses the handleAsyncErrors higher-order function to wrap the original fetchEmployees function.
This setup automatically handles any errors that might occur during the API call, logging them and returning null to indicate an error state. The consumer of safeFetchEmployees can then check if the returned value is null to determine if the operation was successful or if an error occurred.
Promise.allAs mentioned earlier, there are times when we need promises to execute in parallel.
Let’s look at an example from our employee API. Say we first need to fetch all employees, then fetch their names, and then generate an email from the names. Obviously, we’ll need to execute the functions in a synchronous manner and also in parallel so that one doesn’t block the other.
In this case, we would make use of Promise.all. According to Mozilla, “Promise.all is typically used after having started multiple asynchronous tasks to run concurrently and having created promises for their results so that one can wait for all the tasks being finished.”
In pseudocode, we’d have something like this:
/employeeid from each user. Fetch each user => /employee/{id}const baseApi = 'https://reqres.in/api/users?page=1'
const userApi = 'https://reqres.in/api/user'
const fetchAllEmployees = async (url: string): Promise<Employee[]> => {
const response = await fetch(url)
const { data } = await response.json()
return data
}
const fetchEmployee = async (url: string, id: number): Promise<Record<string, string>> => {
const response = await fetch(`${url}/${id}`)
const { data } = await response.json()
return data
}
const generateEmail = (name: string): string => {
return `${name.split(' ').join('.')}@company.com`
}
const runAsyncFunctions = async () => {
try {
const employees = await fetchAllEmployees(baseApi)
Promise.all(
employees.map(async user => {
const userName = await fetchEmployee(userApi, user.id)
const emails = generateEmail(userName.name)
return emails
})
)
} catch (error) {
console.log(error)
}
}
runAsyncFunctions()
In the above code, fetchEmployees fetches all the employees from the baseApi. We await the response, convert it to JSON, and then return the converted data.
The most important concept to keep in mind is how we sequentially executed the code line by line inside the async function with the await keyword. We’d get an error if we tried to convert data to JSON that has not been fully awaited. The same concept applies to fetchEmployee, except that we’d only fetch a single employee. The more interesting part is the runAsyncFunctions, where we run all the async functions concurrently.
First, wrap all the methods within runAsyncFunctions inside a try/catch block. Next, await the result of fetching all the employees. We need the id of each employee to fetch their respective data, but what we ultimately need is information about the employees.
This is where we can call upon Promise.all to handle all the Promises concurrently. Each fetchEmployee Promise is executed concurrently for all the employees. The awaited data from the employees’ information is then used to generate an email for each employee with the generateEmail function.
In the case of an error, it propagates as usual, from the failed promise to Promise.all, and then becomes an exception we can catch inside the catch block.
Promise.allSettledPromise.all is great when we need all promises to succeed, but real-world applications often need to handle situations where some operations might fail while others succeed. Let’s consider our employee management system: What if we need to update multiple employee records, but some updates might fail due to validation errors or network issues?
This is where Promise.allSettled comes in handy. Unlike Promise.all, which fails completely if any promise fails, Promise.allSettled will wait for all promises to complete, regardless of whether they succeed or fail. It gives us information about both successful and failed operations.
Let’s enhance our employee management system to handle bulk updates:
interface UpdateResult {
id: number;
success: boolean;
message: string;
}
const updateEmployee = async (employee: Employee): Promise<UpdateResult> => {
const api = `${userApi}/${employee.id}`;
try {
const response = await fetch(api, {
method: 'PUT',
body: JSON.stringify(employee),
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
return {
id: employee.id,
success: true,
message: 'Update successful'
};
} catch (error) {
return {
id: employee.id,
success: false,
message: error instanceof Error ? error.message : 'Update failed'
};
}
};
const bulkUpdateEmployees = async (employees: Employee[]) => {
const updatePromises = employees.map(emp => updateEmployee(emp));
const results = await Promise.allSettled(updatePromises);
// Process results and generate a report
const summary = results.reduce((acc, result, index) => {
if (result.status === 'fulfilled') {
acc.successful.push(result.value);
} else {
acc.failed.push({
id: employees[index].id,
error: result.reason
});
}
return acc;
}, {
successful: [] as UpdateResult[],
failed: [] as Array<{id: number; error: any}>
});
return summary;
};
Think of Promise.allSettled like a project manager tracking multiple tasks. Instead of stopping everything when one task fails (like Promise.all would), the manager continues monitoring all tasks and provides a complete report of what succeeded and what failed. This is particularly useful when you need to:
for await...ofSometimes we need to process large amounts of data that come in chunks or pages. Imagine you’re exporting employee data from a large enterprise system – there might be thousands of records that come in batches to prevent memory overload.
The for await...of loop is perfect for this scenario. It allows us to process asynchronous data streams one item at a time, making our code both efficient and readable. Here’s how we can use it with our employee system:
interface PaginatedResponse<T> {
data: T[];
nextPage?: string;
}
async function* fetchAllPages<T>(
initialUrl: string,
fetchPage: (url: string) => Promise<PaginatedResponse<T>>
): AsyncIterableIterator<T> {
let currentUrl = initialUrl;
while (currentUrl) {
const response = await fetchPage(currentUrl);
for (const item of response.data) {
yield item;
}
currentUrl = response.nextPage || '';
}
}
// Usage with type safety
async function processAllEmployee() {
const fetchPage = async (url: string): Promise<PaginatedResponse<Employee>> => {
const response = await fetch(url);
return response.json();
};
try {
for await (const employee of fetchAllPages('/api/employees', fetchPage)) {
// Process each employee as they come in
console.log(`Processing employee: ${employee.employee_name}`);
await updateEmployeeAnalytics(employee);
}
} catch (error) {
console.error('Failed to process employees:', error);
}
}
function updateEmployeeAnalytics(employee: Employee) { /** custom logic */}
Think of for await...of like a conveyor belt in a factory. Instead of waiting for all products (data) to be manufactured before starting to pack them (process them), we can pack each product as it comes off the belt. This approach has several benefits:
Combining higher-order functions with async/await creates powerful patterns for handling asynchronous operations.
When working with our employee management system, we often need to process arrays of data asynchronously. Let’s see how we can effectively use array methods with async/await:
// Async filter: Keep only active employees
async function filterActiveEmployees(employees: Employee[]) {
const checkResults = await Promise.all(
employees.map(async (employee) => {
const status = await checkEmployeeStatus(employee.id);
return { employee, isActive: status === 'active' };
})
);
return checkResults
.filter(result => result.isActive)
.map(result => result.employee);
}
// Async reduce: Calculate total department salary
async function calculateDepartmentSalary(employeeIds: number[]) {
return await employeeIds.reduce(async (promisedTotal, id) => {
const total = await promisedTotal;
const employee = await fetchEmployeeDetails(id);
return total + employee.salary;
}, Promise.resolve(0)); // Initial value must be a Promise
}
When working with these array methods, there are some important considerations:
map with async operations returns an array of promises that need to be handled with Promise.allfilter requires special handling because we can’t directly use the promise result as a filter conditionreduce with async operations needs careful promise handling for the accumulatorThere are use cases where utility functions are needed to carry out some operations on responses returned from asynchronous calls. We can create reusable higher-order functions that wrap async operations with these additional functionalities:
// Higher-order function for caching async results
function withCache<T>(
asyncFn: (id: number) => Promise<T>,
ttlMs: number = 5000
) {
const cache = new Map<number, { data: T; timestamp: number }>();
return async (id: number): Promise<T> => {
const cached = cache.get(id);
const now = Date.now();
if (cached && now - cached.timestamp < ttlMs) {
return cached.data;
}
const data = await asyncFn(id);
cache.set(id, { data, timestamp: now });
return data;
};
}
// Usage example
const cachedFetchEmployee = withCache(async (id: number) => {
const response = await fetch(`${baseApi}/employee/${id}`);
return response.json();
});
In this above snippet, the withCache higher-order function adds caching capability to any async function that fetches data by ID. If the same ID is requested multiple times within five seconds (the default TTL), the function returns the cached result instead of making another API call. This significantly reduces unnecessary network requests when the same employee data is needed multiple times in quick succession.
Awaited typeAwaited is a utility type that models operations like await in async functions. It unwraps the resolved value of a promise, discarding the promise itself, and works recursively, thereby removing any nested promise layers.
Awaited is the type of value that you expect to get after awaiting a promise. It helps your code understand that once you use await, you’re not dealing with a promise anymore, but with the actual data you wanted.
Here’s the basic syntax:
type MyPromise = Promise<string>; type AwaitedType = Awaited<MyPromise>; // AwaitedType will be 'string'
The Awaited type does not exactly model the then method in promises, however Awaited can be relevant when using then in async functions. If you use await inside a then callback, Awaited helps infer the type of the awaited value, avoiding the need for additional type annotations.
Awaited can help clarify the type of data and awaitedValue in async functions, even when using then for promise chaining. However, it doesn’t replace the functionality of then itself.
async and await enable us to write asynchronous code in a way that looks and behaves like synchronous code. This makes the code much easier to read, write, and understand.
Here are some key concepts to keep in mind as you’re working on your next asynchronous project in TypeScript:
LogRocket lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks, and with plugins to log additional context from Redux, Vuex, and @ngrx/store.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you understand your web and mobile apps — start monitoring for free.
await only works inside an async functionasync keyword always returns a Promiseasync doesn’t return a Promise, it will be wrapped in an immediately resolved Promiseawait keyword is encountered until a Promise is completedawait will either return a result from a fulfilled Promise or throw an exception from a rejected Promise
:has(), with examplesThe CSS :has() pseudo-class is a powerful new feature that lets you style parents, siblings, and more – writing cleaner, more dynamic CSS with less JavaScript.

Kombai AI converts Figma designs into clean, responsive frontend code. It helps developers build production-ready UIs faster while keeping design accuracy and code quality intact.

Discover what’s new in The Replay, LogRocket’s newsletter for dev and engineering leaders, in the October 22nd issue.

John Reilly discusses how software development has been changed by the innovations of AI: both the positives and the negatives.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now
2 Replies to "A guide to async/await in TypeScript"
Logrocket does not catch uncaught promise rejections (at least in our case).
It can catch uncaught promise rejections—it just doesn’t catch them automatically. You can manually set it up to do so!