Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 await points 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#TypeScriptBehavior on failure
Task.WhenAll()Promise.all()Waits for all (C#) / short-circuits on first (TS)
Task.WhenAll() + inspect each taskPromise.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

ConceptC#TypeScript
Runtime modelThread pool, multiple threadsSingle-threaded event loop
Async primitiveTask<T> / ValueTask<T>Promise<T>
ConfigureAwait(false)Required in library codeDoes not exist; not needed
Parallel executionTask.WhenAll()Promise.all()
First-to-completeTask.WhenAny()Promise.race()
All results including failuresInspect each Task after WhenAllPromise.allSettled()
Failure on first successNo direct equivalentPromise.any()
Error type from parallel opsAggregateException (all errors)First rejection (single error)
Error type in catchTyped as declared exceptionunknown (must narrow)
Top-level awaitC# 9+ top-level statementsES modules only
CPU-bound parallelismTask.Run() + thread poolWorker Threads (separate module)
CancellationCancellationTokenAbortController / AbortSignal
Unhandled async errorsCrash 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# PatternTypeScript EquivalentNotes
Task<T>Promise<T>Both represent eventual values
ValueTask<T>No equivalent; Promises have no sync fast-path
await taskawait promiseIdentical 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
AggregateExceptionFirst rejection errorTS does not aggregate
CancellationTokenAbortController / AbortSignalPass signal to fetch() and compatible APIs
ConfigureAwait(false)Does not exist; not needed
Task.Run(() => work)Worker ThreadsDifferent API; only for CPU-bound work
Task.Delay(ms)new Promise(r => setTimeout(r, ms))Or use a delay utility
Task.CompletedTaskPromise.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 + awaitfor...of + awaitNever use forEach with async callbacks
Task.WhenAll + .mapPromise.all(arr.map(fn))Parallel async over a collection
Unobserved task exceptionUnhandled rejectionRegister 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