Skip to content

TypeScript Async/Await — Complete Pattern Guide

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about TypeScript Async/Await. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript async/await with full type inference makes asynchronous code as readable as synchronous code while ensuring that promise rejections, parallel execution, and concurrency patterns are all type-checked at compile time.

What You'll Learn

  • Async function types and generics
  • Parallel vs sequential execution
  • Typed error handling in async code
  • Async iterators and generators
  • Promise pooling and concurrency limits
  • AbortController for cancellation

Why It Matters

JavaScript's single-threaded event loop makes non-blocking code essential. Without TypeScript, async code is prone to type errors — a promise resolving with User but consumed as Admin, or forgetting to handle a rejection path. TypeScript ensures the entire async flow is type-safe.

Real-World Use

The DodaZIP cloud sync service processes thousands of concurrent file uploads using typed async patterns. Each upload is a tracked promise with progress, cancellation, and typed error handling — TypeScript ensures the upload pipeline never mixes file metadata types.

Learning Path

flowchart LR
  A[Error Handling] --> B[Async Patterns]
  B --> C[Pattern Matching]
  B --> D[You Are Here]
  C --> E[Performance]
  D --> F[Project: REST API]

Typed Async Functions

Every async function returns a Promise<T>. TypeScript infers the resolved type:

// TypeScript infers: () => Promise<string>
async function fetchGreeting(): Promise<string> {
  return 'Hello, World!';
}

// Explicit generic — useful for complex types
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  return response.json(); // Inferred as User because of return type
}

Expected output:

const greeting: string = await fetchGreeting();
const user: User = await fetchUser('123');

Parallel vs Sequential Execution

Understanding when to await sequentially vs in parallel is critical for performance:

// ❌ Sequential — slow
async function loadDataSequential() {
  const start = Date.now();

  const user = await fetchUser('123');
  const posts = await fetchPosts('123');
  const notifications = await fetchNotifications('123');

  console.log(`Sequential took ${Date.now() - start}ms`);
  return { user, posts, notifications };
}

// ✅ Parallel — fast when operations are independent
async function loadDataParallel() {
  const start = Date.now();

  const [user, posts, notifications] = await Promise.all([
    fetchUser('123'),
    fetchPosts('123'),
    fetchNotifications('123'),
  ]);

  console.log(`Parallel took ${Date.now() - start}ms`);
  return { user, posts, notifications };
}

Why it matters: Sequential requests to independent APIs waste time waiting. Promise.all runs them concurrently, completing when the slowest operation finishes.

Type Safety with Promise.all

TypeScript infers the tuple type from Promise.all:

const result = await Promise.all([
  fetchUser('123'),     // Promise<User>
  fetchPosts('123'),    // Promise<Post[]>
  fetchNotifications(), // Promise<Notification[]>
]);
// result is typed as [User, Post[], Notification[]]

const user = result[0]; // User
const posts = result[1]; // Post[]

Async Iterators and Generators

Async generators produce sequences of values over time — useful for paginated APIs and streams:

interface Page<T> {
  items: T[];
  nextCursor?: string;
}

async function* paginateUsers(cursor?: string): AsyncGenerator<User[], void, undefined> {
  do {
    const page = await fetchPageOfUsers(cursor);
    yield page.items;

    cursor = page.nextCursor;
  } while (cursor);
}

// Usage — for-await-of consumes the generator
async function processAllUsers() {
  let total = 0;
  for await (const users of paginateUsers()) {
    total += users.length;
    console.log(`Processed ${users.length} users (total: ${total})`);
  }
}

Async Iterator for Stream Processing

class DataStream<T> implements AsyncIterable<T> {
  constructor(private data: T[], private delayMs = 100) {}

  [Symbol.asyncIterator](): AsyncIterator<T> {
    let index = 0;
    const data = this.data;
    const delay = this.delayMs;

    return {
      async next(): Promise<IteratorResult<T>> {
        if (index >= data.length) {
          return { done: true, value: undefined as unknown as T };
        }

        await new Promise((r) => setTimeout(r, delay));
        return { done: false, value: data[index++] };
      },
    };
  }
}

// Usage
const stream = new DataStream([1, 2, 3, 4, 5], 200);

for await (const value of stream) {
  console.log(value); // Logs 1, 2, 3, 4, 5 with 200ms intervals
}

Concurrency Control with Promise Pooling

Running 1,000 promises simultaneously can overwhelm system resources. Limit concurrency:

async function promisePool<T>(
  items: T[],
  concurrency: number,
  handler: (item: T) => Promise<void>
): Promise<void> {
  const queue = [...items];
  const workers: Promise<void>[] = [];

  for (let i = 0; i < concurrency; i++) {
    workers.push(worker());
  }

  async function worker(): Promise<void> {
    while (queue.length > 0) {
      const item = queue.shift()!;
      await handler(item);
    }
  }

  await Promise.all(workers);
}

// Process 100 files with 5 concurrent uploads
const files = Array.from({ length: 100 }, (_, i) => `file_${i}.txt`);

await promisePool(files, 5, async (file) => {
  console.log(`Uploading ${file}...`);
  await uploadFile(file);
  console.log(`Uploaded ${file}`);
});

Generic Promise Pool with Results

async function promisePoolAll<T, R>(
  items: T[],
  concurrency: number,
  handler: (item: T) => Promise<R>
): Promise<R[]> {
  const results: R[] = [];
  const queue = [...items.entries()]; // [index, item]

  const workers = Array.from({ length: Math.min(concurrency, items.length) }, worker);

  async function worker(): Promise<void> {
    while (queue.length > 0) {
      const [index, item] = queue.shift()!;
      results[index] = await handler(item);
    }
  }

  await Promise.all(workers);
  return results;
}

// Usage
const results = await promisePoolAll(files, 5, async (file) => {
  return uploadFile(file); // Each upload returns a result
});
// results is typed as R[] matching the return type of uploadFile

AbortController for Cancellation

TypeScript has full types for AbortController and AbortSignal:

async function fetchWithTimeout<T>(
  url: string,
  timeoutMs = 5000
): Promise<T> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

// Advanced: cancellable operation with status tracking
interface CancellablePromise<T> {
  promise: Promise<T>;
  cancel: () => void;
}

function cancellable<T>(
  executor: (signal: AbortSignal) => Promise<T>
): CancellablePromise<T> {
  const controller = new AbortController();

  const promise = executor(controller.signal).finally(() => {
    // Cleanup if needed
  });

  return {
    promise,
    cancel: () => controller.abort(),
  };
}

// Usage
const { promise, cancel } = cancellable(async (signal) => {
  const response = await fetch('/api/large-file', { signal });
  return response.json();
});

// Cancel if it takes too long
setTimeout(() => cancel(), 3000);
const data = await promise; // Throws if cancelled

Async Queue Pattern

Process tasks with backpressure:

class AsyncQueue<T> {
  private queue: T[] = [];
  private resolvers: ((value: T) => void)[] = [];

  push(item: T): void {
    if (this.resolvers.length > 0) {
      const resolve = this.resolvers.shift()!;
      resolve(item);
    } else {
      this.queue.push(item);
    }
  }

  async pop(): Promise<T> {
    if (this.queue.length > 0) {
      return this.queue.shift()!;
    }

    return new Promise((resolve) => {
      this.resolvers.push(resolve);
    });
  }

  get length(): number {
    return this.queue.length;
  }
}

// Producer-consumer pattern
const queue = new AsyncQueue<string>();

// Producer
async function producer() {
  for (let i = 0; i < 10; i++) {
    await new Promise((r) => setTimeout(r, 100));
    queue.push(`Task ${i}`);
  }
}

// Consumer
async function consumer() {
  for (let i = 0; i < 10; i++) {
    const task = await queue.pop();
    console.log(`Processing: ${task}`);
  }
}

await Promise.all([producer(), consumer()]);

Common Mistakes

1. Sequential Promise.all when independent awaited

Awaiting promises sequentially instead of with Promise.all adds unnecessary latency to operations that could run in parallel.

2. Forgetting to handle rejections

An unhandled promise rejection crashes the process in Node.js. Always add .catch() or use try-catch with async/await.

3. Not using AbortController for fetch timeouts

Fetch requests without timeouts can hang indefinitely. Always pair fetch with AbortController and a timeout.

4. Running too many promises at once

1,000 concurrent requests overwhelm APIs and system resources. Use promise pooling to limit concurrency.

5. Ignoring async generator cleanup

Async generators that acquire resources (file handles, database connections) must clean up in finally blocks or return handlers.

6. Overusing async when not needed

Synchronous operations wrapped in async create unnecessary microtasks. Only use async for I/O operations.

7. Not typing async function return values

Omitting the return type on async functions hides the resolved type. Always annotate Promise<T> with the expected type.

Practice Questions

  1. What's the difference between Promise.all and Promise.allSettled? Promise.all rejects immediately if any promise rejects. Promise.allSettled waits for all promises and returns their results regardless of rejection.

  2. How do you cancel an async operation in TypeScript? Use AbortController — pass AbortSignal to fetch or custom async operations, and call controller.abort() to cancel.

  3. What does an async generator function return? An AsyncGenerator<T, TReturn, TNext> object that implements AsyncIterable<T> and can be consumed with for-await-of.

  4. How do you type the resolved value of Promise.all with an array? TypeScript infers the tuple type from an array literal in Promise.all. For dynamic arrays, use Promise.all<T>(items) with a generic.

  5. What's backpressure and why does it matter? Backpressure means controlling the rate of data flow so producers don't overwhelm consumers. Async queues with bounded buffers implement backpressure.

Challenge

Build a concurrent file processor that reads 1,000 files with a concurrency limit of 10, processes each file through a pipeline (read → transform → compress → upload), supports cancellation via AbortController, and tracks progress with typed status updates.

FAQ

Should I use async/await or raw promises?

Async/await is preferred for readability. Use raw Promise methods (Promise.all, Promise.race) for composition patterns.

How do I type Promise.all with mixed promise types?

Pass promises as a tuple: const [a, b] = await Promise.all([promise1, promise2]). TypeScript infers the tuple types correctly.

Can I use async/await with Array.forEach?

No — forEach doesn't await each callback. Use for...of with async/await for sequential, or Promise.all with map for parallel.

What's the difference between microtasks and macrotasks?

Promises (.then/catch/async/await) are microtasks — they execute before the next event loop tick. setTimeout/setInterval are macrotasks.

How do I handle async errors without try-catch everywhere?

Use a Result type as shown in the Error Handling lesson, or create a wrapper function that catches and transforms errors centrally.

Is async/await slower than callbacks?

The overhead is negligible in practice. The readability and error-handling benefits of async/await far outweigh any micro-performance difference.

Mini Project

Build a concurrent web scraper:

  • Async generator: Paginate through 50 API pages
  • Promise pool: Scrape 5 pages concurrently
  • AbortController: Cancel if total time exceeds 30 seconds
  • Async queue: Buffer scraped items for processing
  • Result tracking: Typed results for each page with success/error status

What's Next

You've mastered async patterns with TypeScript. Now explore pattern matching with {{< ref "53-pattern-matching" >}}, or optimize performance with {{< ref "54-performance" >}}.

For a hands-on project, see {{< ref "55-project-rest-api" >}}.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro