Async/Await: Same Keywords, Different Universe
For .NET engineers who know: C#
async/await,Task<T>,Task.WhenAll(),ConfigureAwait(false), and the thread pool model You’ll learn: Why TypeScript’s async/await looks identical to C#’s but operates on a completely different runtime model — and the specific traps that catch .NET engineers off guard Time: 15-20 minutes
The .NET Way (What You Already Know)
In C#, async/await is a compiler transformation over the thread pool. When you await a Task, you yield the current thread back to the thread pool. The runtime captures the current SynchronizationContext and, when the awaited work completes, resumes your code on an appropriate thread — either the original context thread (in ASP.NET Framework or UI apps) or any thread pool thread (in ASP.NET Core, which has no SynchronizationContext).
This is why ConfigureAwait(false) exists: in library code, you explicitly opt out of capturing the synchronization context to avoid deadlocks and improve throughput. In ASP.NET Core you mostly don’t need it, but you’ve probably written it a thousand times in library code, or been told to.
// C# — Await yields the current thread; another thread resumes the continuation
public async Task<Order> GetOrderAsync(int id)
{
// Thread pool thread T1 starts here
var order = await _db.Orders.FindAsync(id);
// T1 is released during the DB call
// Thread pool thread T2 (possibly the same as T1) resumes here
var tax = await _taxService.CalculateAsync(order);
// T2 is released; another thread picks up the continuation
return order with { Tax = tax };
}
The Task<T> type is the fundamental unit of async work. Task.WhenAll() runs tasks in parallel. Task.WhenAny() returns when the first task completes. AggregateException wraps multiple failures from parallel operations.
You also know that forgetting await compiles fine and produces a Task<T> instead of T — a silent bug you’ve probably chased.
The TypeScript Way
There Are No Threads
Node.js is single-threaded. There is no thread pool, no synchronization context, and no thread switching. When you await a Promise in TypeScript, you yield to the event loop — a single-threaded scheduler that processes callbacks, I/O events, and timers in phases.
// TypeScript — Await yields to the event loop; the same thread resumes the continuation
async function getOrder(id: number): Promise<Order> {
// The single thread starts here
const order = await db.orders.findUnique({ where: { id } });
// The thread is released to the event loop during the I/O wait
// The same thread resumes here when the DB responds
const tax = await taxService.calculate(order);
// Same thread again
return { ...order, tax };
}
The execution model looks the same from inside the async function, but the runtime mechanics are completely different. In C#, multiple threads may touch your async chain. In TypeScript, one thread always does — it just handles other things between your awaits.
This has real consequences:
- No race conditions on shared mutable state (within a single Node.js process, between async operations). Two
awaitpoints cannot interleave on different threads because there is only one thread. - CPU-bound work blocks everything. A tight computation loop blocks the event loop entirely. There is no
Task.Run(() => HeavyCpuWork())equivalent that offloads to a thread pool — you need Worker Threads for that, which is a different topic. - No
ConfigureAwait(false). There is no synchronization context to capture. You will never write it; you do not need to think about it.
Promises vs. Tasks
Promise<T> is the TypeScript equivalent of Task<T>. The mapping is close but not exact.
// TypeScript
const promise: Promise<string> = fetch("https://api.example.com/data")
.then((res) => res.json())
.then((data) => data.name);
// C# equivalent
Task<string> task = httpClient
.GetAsync("https://api.example.com/data")
.ContinueWith(t => t.Result.Content.ReadFromJsonAsync<Response>())
.ContinueWith(t => t.Result.Name);
With async/await, both collapse into the same readable form:
// TypeScript
async function getName(): Promise<string> {
const res = await fetch("https://api.example.com/data");
const data = await res.json();
return data.name;
}
// C#
async Task<string> GetNameAsync()
{
var res = await _httpClient.GetAsync("https://api.example.com/data");
var data = await res.Content.ReadFromJsonAsync<Response>();
return data.Name;
}
One critical difference: Promise is eager. The moment you create a Promise, the work starts. Task can be “hot” (already running) or “cold” depending on how it was created, but most APIs return hot tasks. For practical purposes, treat both as: work starts when the object is created.
Promise.all() vs. Task.WhenAll()
Running operations in parallel is one of the most common async patterns, and this is where .NET engineers make their first performance mistake in TypeScript.
// C# — Parallel execution with Task.WhenAll
async Task<(User user, IEnumerable<Order> orders)> GetUserWithOrdersAsync(int userId)
{
var userTask = _userService.GetByIdAsync(userId);
var ordersTask = _orderService.GetByUserAsync(userId);
await Task.WhenAll(userTask, ordersTask);
return (userTask.Result, ordersTask.Result);
}
// TypeScript — Parallel execution with Promise.all
async function getUserWithOrders(
userId: number
): Promise<{ user: User; orders: Order[] }> {
const [user, orders] = await Promise.all([
userService.getById(userId),
orderService.getByUser(userId),
]);
return { user, orders };
}
Promise.all() takes an array of Promises and returns a single Promise that resolves when all input Promises resolve. If any one rejects, the returned Promise rejects immediately with that error — the others are abandoned (they still run to completion, but their results are discarded).
This mirrors Task.WhenAll() behavior for the failure case. The difference is that Task.WhenAll() waits for all tasks to finish before throwing, aggregating all exceptions into an AggregateException. Promise.all() short-circuits on the first rejection. If you need all results — successes and failures — use Promise.allSettled().
// TypeScript — Collect all results, including failures
async function getUserAndOrders(userId: number) {
const results = await Promise.allSettled([
userService.getById(userId),
orderService.getByUser(userId),
]);
// results[0].status === 'fulfilled' | 'rejected'
for (const result of results) {
if (result.status === "rejected") {
console.error("One operation failed:", result.reason);
} else {
// result.value is the resolved value
}
}
}
// C# — Task.WhenAll() always waits for all tasks; exceptions are aggregated
async Task GetAllAsync()
{
try
{
await Task.WhenAll(task1, task2, task3);
}
catch (AggregateException ex)
{
// ex.InnerExceptions contains ALL failures
foreach (var inner in ex.InnerExceptions)
Console.WriteLine(inner.Message);
}
}
| C# | TypeScript | Behavior on failure |
|---|---|---|
Task.WhenAll() | Promise.all() | Waits for all (C#) / short-circuits on first (TS) |
Task.WhenAll() + inspect each task | Promise.allSettled() | All results collected, no short-circuit |
Task.WhenAny() | Promise.race() | First to complete wins |
| — | Promise.any() | First to succeed wins (ignores rejections) |
Promise.race() vs. Task.WhenAny()
Promise.race() resolves or rejects as soon as the first Promise in the array settles — for better or worse.
// C# — Return whichever completes first
async Task<string> GetFastestResultAsync()
{
var task1 = FetchFromPrimaryAsync();
var task2 = FetchFromSecondaryAsync();
var firstTask = await Task.WhenAny(task1, task2);
return await firstTask; // unwrap the result
}
// TypeScript — Same pattern, cleaner syntax
async function getFastestResult(): Promise<string> {
return await Promise.race([fetchFromPrimary(), fetchFromSecondary()]);
}
A common use case in both ecosystems is implementing a timeout:
// TypeScript — Timeout pattern
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
// Usage
const result = await withTimeout(slowApiCall(), 5000);
// C# equivalent — CancellationToken is the idiomatic approach,
// but you can also race against Task.Delay
async Task<string> WithTimeoutAsync(Task<string> task, int ms)
{
var timeoutTask = Task.Delay(ms).ContinueWith(_ =>
throw new TimeoutException());
var winner = await Task.WhenAny(task, timeoutTask);
return await winner;
}
Error Handling
In C#, exceptions propagate through await naturally. A faulted Task throws when you await it. AggregateException appears when you call .Result or .Wait() directly, or when Task.WhenAll() collects multiple failures.
In TypeScript, a rejected Promise throws when you await it. The try/catch blocks look identical:
// TypeScript
async function processOrder(id: number): Promise<void> {
try {
const order = await orderService.get(id);
await paymentService.charge(order);
await notificationService.send(order);
} catch (err) {
// err is typed as 'unknown' in strict TypeScript
if (err instanceof PaymentError) {
await orderService.markFailed(id);
}
throw err; // re-throw if not handled
}
}
// C#
async Task ProcessOrderAsync(int id)
{
try
{
var order = await _orderService.GetAsync(id);
await _paymentService.ChargeAsync(order);
await _notificationService.SendAsync(order);
}
catch (PaymentException ex)
{
await _orderService.MarkFailedAsync(id);
throw;
}
}
The structural difference: in TypeScript catch, the error is typed as unknown (with strict mode and useUnknownInCatchVariables). You must narrow the type before using it. This is the correct behavior — JavaScript throw can throw anything, not just Error objects.
// TypeScript — Proper error narrowing
try {
await riskyOperation();
} catch (err) {
// err: unknown
if (err instanceof Error) {
console.error(err.message); // now safe
console.error(err.stack); // now safe
} else {
console.error("Unknown error:", String(err));
}
}
There is no AggregateException in TypeScript. When Promise.all() rejects, you get the first rejection error directly — it is not wrapped. If you need to inspect all errors from parallel operations, use Promise.allSettled().
The Callback Era (Context You Need)
JavaScript had async/await added in 2017. Before that, async code used callbacks:
// The callback hell you'll see in old code
fs.readFile("data.json", "utf8", function (err, data) {
if (err) {
console.error(err);
return;
}
JSON.parse(data, function (parseErr, parsed) {
// This isn't even real API — just illustrating the nesting
database.save(parsed, function (saveErr, result) {
if (saveErr) {
console.error(saveErr);
return;
}
console.log("Saved:", result);
});
});
});
Promises arrived first as a library pattern, then as a language primitive. async/await is syntax sugar over Promises — await unwraps a Promise, and an async function always returns a Promise.
You’ll encounter callback-style APIs in Node.js core (many fs, http, crypto functions still have callback variants). The standard way to promisify them is util.promisify():
import { promisify } from "util";
import { readFile } from "fs";
const readFileAsync = promisify(readFile);
async function readConfig(): Promise<string> {
const buffer = await readFileAsync("config.json", "utf8");
return buffer;
}
Modern Node.js provides fs/promises (and similar modules for other core APIs) that are natively Promise-based, so you rarely need promisify in new code. But when you’re working with third-party libraries from the callback era, you’ll need it.
The Async IIFE Pattern
In C#, you can’t use await at the top level of a class or static method without wrapping it in an async method — though C# 9 added top-level statements that allow it in Program.cs.
In older JavaScript and in some module contexts, you’ll see the async IIFE (immediately invoked function expression) pattern, which creates and immediately calls an async function to get a scope where await is valid:
// Async IIFE — creates an async scope and executes immediately
(async () => {
const data = await fetchSomeData();
console.log(data);
})();
// The outer () invokes the async function immediately
// Any rejection here is an unhandled rejection — you must catch it
(async () => {
try {
const data = await fetchSomeData();
console.log(data);
} catch (err) {
console.error("Top-level failure:", err);
process.exit(1);
}
})();
Modern TypeScript and Node.js support top-level await in ES modules (files with "type": "module" in package.json, or .mts files). This eliminates the need for the IIFE pattern in most cases:
// Top-level await — works in ES modules
const config = await loadConfig();
const db = await connectToDatabase(config.databaseUrl);
console.log("Server ready");
You’ll still encounter async IIFEs in non-module contexts, in test setup code, and in older codebases. Recognize the pattern; you rarely need to write it yourself.
Key Differences
| Concept | C# | TypeScript |
|---|---|---|
| Runtime model | Thread pool, multiple threads | Single-threaded event loop |
| Async primitive | Task<T> / ValueTask<T> | Promise<T> |
ConfigureAwait(false) | Required in library code | Does not exist; not needed |
| Parallel execution | Task.WhenAll() | Promise.all() |
| First-to-complete | Task.WhenAny() | Promise.race() |
| All results including failures | Inspect each Task after WhenAll | Promise.allSettled() |
| Failure on first success | No direct equivalent | Promise.any() |
| Error type from parallel ops | AggregateException (all errors) | First rejection (single error) |
Error type in catch | Typed as declared exception | unknown (must narrow) |
Top-level await | C# 9+ top-level statements | ES modules only |
| CPU-bound parallelism | Task.Run() + thread pool | Worker Threads (separate module) |
| Cancellation | CancellationToken | AbortController / AbortSignal |
| Unhandled async errors | Crash by default (modern .NET) | Warning by default; crash with flag |
Gotchas for .NET Engineers
Gotcha 1: Forgetting await Is Silent and Produces the Wrong Type
In C#, forgetting await gives you a Task<T> where you expected a T. The compiler often catches this because you’ll try to use a Task<User> where a User is expected, and it won’t compile.
In TypeScript, the same mistake still compiles and runs — you get a Promise<User> where you expected a User. If you then pass it to something that accepts any or unknown, or log it, it prints [object Promise] and no error is thrown.
// This compiles. It is wrong.
async function processUser(id: number): Promise<void> {
const user = userService.getById(id); // Missing await
// user is Promise<User>, not User
console.log(user.name); // undefined — Promise has no .name property
// TypeScript may warn here if types are strict, but won't always
}
// This is also silently broken
async function saveAndReturn(data: UserInput): Promise<User> {
const user = userService.create(data); // Missing await
return user; // Returns Promise<User>, which async wraps in another Promise
// Caller gets Promise<Promise<User>> — this actually works by accident
// because await on a thenable unwraps recursively. But the intent is wrong.
}
Enable strict TypeScript and use no-floating-promises in your ESLint configuration. It catches Promises that are created but not awaited or returned:
// .eslintrc or eslint.config.mjs
{
"rules": {
"@typescript-eslint/no-floating-promises": "error"
}
}
Gotcha 2: Sequential Execution When You Intended Parallel
This is the single most common performance mistake .NET engineers make when writing TypeScript for the first time. It looks correct and runs fine — it’s just slow.
// WRONG — Sequential. Each await blocks the next.
async function getDashboardData(userId: number) {
const user = await userService.getById(userId); // waits ~50ms
const orders = await orderService.getByUser(userId); // waits ~80ms
const notifications = await notificationService.getUnread(userId); // waits ~30ms
return { user, orders, notifications };
// Total: ~160ms
}
// CORRECT — Parallel. All three start immediately.
async function getDashboardData(userId: number) {
const [user, orders, notifications] = await Promise.all([
userService.getById(userId),
orderService.getByUser(userId),
notificationService.getUnread(userId),
]);
return { user, orders, notifications };
// Total: ~80ms (the slowest single operation)
}
The sequential version is not wrong in the way a bug is wrong — it produces correct results. It is wrong in the way a performance anti-pattern is wrong. In C#, you’d naturally reach for Task.WhenAll() here. In TypeScript, train yourself to reach for Promise.all() whenever you have independent operations.
The only time sequential await is correct is when each operation depends on the result of the previous one.
Gotcha 3: Unhandled Promise Rejections Are Dangerous
In C#, forgetting to await a Task that throws results in an unobserved task exception. Modern .NET raises TaskScheduler.UnobservedTaskException and, depending on configuration, may terminate the process.
In Node.js, unhandled Promise rejections print a warning and, since Node.js 15, terminate the process with exit code 1:
UnhandledPromiseRejectionWarning: Error: DB connection failed
at Object.<anonymous> (server.ts:12:15)
This means that fire-and-forget async patterns — which might be acceptable in .NET with proper exception handling — are dangerous in Node.js:
// DANGEROUS — If sendEmail rejects, the process may crash
function handleUserSignup(user: User): void {
emailService.sendWelcome(user); // Missing await, no .catch()
// Execution continues, but a rejection floats unhandled
}
// SAFE — Explicitly handle the floating promise
function handleUserSignup(user: User): void {
emailService
.sendWelcome(user)
.catch((err) => logger.error("Welcome email failed", { userId: user.id, err }));
}
Register a global handler to catch anything that slips through:
// In your server startup
process.on("unhandledRejection", (reason, promise) => {
logger.error("Unhandled promise rejection", { reason, promise });
// In production, crash and let your process manager restart
process.exit(1);
});
Gotcha 4: Promise.all() Abandons Other Promises on First Failure
In C#, Task.WhenAll() waits for all tasks to complete (including failures) before throwing. This means if you start three parallel operations and one fails, the other two still run to completion — their results are just not returned.
Promise.all() short-circuits: the moment one Promise rejects, the returned Promise rejects immediately. The other Promises are not cancelled (there is no cancellation at the Promise level), but their results are discarded.
// If getOrders() rejects after 10ms,
// getNotifications() continues running but its result is discarded.
// This can leave database connections or resources in unexpected states.
const [user, orders, notifications] = await Promise.all([
getUser(userId), // completes in 50ms
getOrders(userId), // rejects in 10ms — Promise.all rejects immediately
getNotifications(userId), // still running, result ignored
]);
If all three operations write to a database or acquire resources, you may end up with partial state. Use Promise.allSettled() when you need to ensure cleanup happens regardless of failures.
Gotcha 5: async in forEach Does Not Work the Way You Expect
This one burns nearly every .NET engineer. In C#, await inside a foreach loop is straightforward — it awaits each iteration before moving to the next.
// C# — Works as expected
foreach (var id in orderIds)
{
await ProcessOrderAsync(id); // Sequential, as intended
}
// BROKEN — forEach does not await the async callback
orderIds.forEach(async (id) => {
await processOrder(id); // These all start in parallel AND forEach returns immediately
});
// Execution continues here before any orders are processed
// CORRECT — Use for...of for sequential async iteration
for (const id of orderIds) {
await processOrder(id); // Properly sequential
}
// CORRECT — Use Promise.all with .map() for parallel async iteration
await Promise.all(orderIds.map((id) => processOrder(id)));
Array.prototype.forEach was designed before Promises existed. It ignores the return value of its callback. An async function returns a Promise, and forEach throws that Promise away. Always use for...of for sequential async loops, or Promise.all() with .map() for parallel async loops.
Hands-On Exercise
The following exercises target the specific patterns that trip up .NET engineers. Work through each one in a TypeScript file you can run with npx tsx exercise.ts.
Setup:
// exercise.ts — paste this, then implement the TODOs below
// Simulated async operations with realistic delays
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchUser(id: number): Promise<{ id: number; name: string }> {
await delay(100);
return { id, name: `User ${id}` };
}
async function fetchOrders(
userId: number
): Promise<Array<{ id: number; total: number }>> {
await delay(150);
return [
{ id: 1, total: 99.99 },
{ id: 2, total: 149.5 },
];
}
async function fetchPermissions(userId: number): Promise<string[]> {
await delay(80);
return ["read", "write"];
}
async function processOrder(orderId: number): Promise<void> {
await delay(50);
if (orderId === 2) throw new Error(`Order ${orderId} failed validation`);
}
Exercise 1 — Fix the Sequential Bottleneck:
// This function takes ~330ms. Rewrite it to take ~150ms.
async function getDashboard(userId: number) {
const user = await fetchUser(userId);
const orders = await fetchOrders(userId);
const permissions = await fetchPermissions(userId);
return { user, orders, permissions };
}
// Verify your answer: log performance.now() before and after calling getDashboard(1)
Exercise 2 — Handle Partial Failures:
// This crashes if any order fails. Rewrite it to process all orders
// and return a summary: { succeeded: number[], failed: Array<{id: number, error: string}> }
async function processAllOrders(orderIds: number[]) {
await Promise.all(orderIds.map(processOrder));
}
// Test with: processAllOrders([1, 2, 3])
// Order 2 always fails — your function should not crash
Exercise 3 — Fix the forEach Bug:
// This function returns before any orders are processed.
// Fix it so all orders complete before returning, running sequentially.
async function processOrdersSequentially(orderIds: number[]): Promise<void> {
orderIds.forEach(async (id) => {
await processOrder(id);
console.log(`Processed order ${id}`);
});
}
Exercise 4 — Implement a Timeout:
// fetchOrders takes 150ms. Implement withTimeout() so this fails:
// await withTimeout(fetchOrders(1), 100);
// And this succeeds:
// await withTimeout(fetchOrders(1), 200);
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
// TODO
}
Expected outputs and solutions are in the appendix at the bottom of this article.
Exercise Solutions:
// Exercise 1 — Parallel execution
async function getDashboard(userId: number) {
const [user, orders, permissions] = await Promise.all([
fetchUser(userId),
fetchOrders(userId),
fetchPermissions(userId),
]);
return { user, orders, permissions };
}
// Exercise 2 — Partial failure handling
async function processAllOrders(orderIds: number[]) {
const results = await Promise.allSettled(orderIds.map(processOrder));
const succeeded: number[] = [];
const failed: Array<{ id: number; error: string }> = [];
results.forEach((result, index) => {
if (result.status === "fulfilled") {
succeeded.push(orderIds[index]);
} else {
failed.push({ id: orderIds[index], error: result.reason.message });
}
});
return { succeeded, failed };
}
// Exercise 3 — Fix forEach
async function processOrdersSequentially(orderIds: number[]): Promise<void> {
for (const id of orderIds) {
await processOrder(id);
console.log(`Processed order ${id}`);
}
}
// Exercise 4 — Timeout
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Operation timed out after ${ms}ms`)), ms)
);
return Promise.race([promise, timeoutPromise]);
}
Quick Reference
| C# Pattern | TypeScript Equivalent | Notes |
|---|---|---|
Task<T> | Promise<T> | Both represent eventual values |
ValueTask<T> | — | No equivalent; Promises have no sync fast-path |
await task | await promise | Identical syntax, different runtime |
Task.WhenAll(t1, t2) | Promise.all([p1, p2]) | TS short-circuits on first failure; C# collects all |
Task.WhenAny(t1, t2) | Promise.race([p1, p2]) | First to settle wins |
| — | Promise.allSettled([p1, p2]) | Collect all results including failures |
| — | Promise.any([p1, p2]) | First to succeed wins |
AggregateException | First rejection error | TS does not aggregate |
CancellationToken | AbortController / AbortSignal | Pass signal to fetch() and compatible APIs |
ConfigureAwait(false) | — | Does not exist; not needed |
Task.Run(() => work) | Worker Threads | Different API; only for CPU-bound work |
Task.Delay(ms) | new Promise(r => setTimeout(r, ms)) | Or use a delay utility |
Task.CompletedTask | Promise.resolve() | Already-resolved Promise |
Task.FromResult(v) | Promise.resolve(v) | Already-resolved with a value |
Task.FromException(ex) | Promise.reject(err) | Already-rejected Promise |
foreach + await | for...of + await | Never use forEach with async callbacks |
Task.WhenAll + .map | Promise.all(arr.map(fn)) | Parallel async over a collection |
| Unobserved task exception | Unhandled rejection | Register process.on('unhandledRejection', ...) |
.Result / .Wait() | — | No synchronous unwrap; always await |
async Task Main() | Top-level await (ESM) | Or async IIFE in non-module context |
Further Reading
- MDN: Using Promises — The definitive reference for Promise semantics, including the microtask queue model
- Node.js Event Loop Documentation — Required reading for understanding the runtime model underneath
async/await - typescript-eslint: no-floating-promises — The ESLint rule that catches unhandled Promises; enable it in every project
- MDN: Promise.allSettled() — The
Promise.all()alternative you’ll reach for once you understand the failure semantics