Skip to content
← All insights
TypeScript16 min read

Handling asynchronous code in TypeScript without losing control

How to compose promises, preserve useful errors, cancel obsolete work, limit concurrency and test asynchronous behaviour without timing-dependent tests.

A Promise is one eventual result, not the operation itself

An async function always returns a Promise. Returning a plain value fulfils it, throwing rejects it, and awaiting another promise pauses only that async function while the surrounding event loop can continue.

A promise can be observed by several consumers, but it represents one result and cannot be restarted. Creating a promise often starts the underlying work immediately, which matters when an array is mapped to async callbacks before concurrency is considered.

Use explicit return types on application boundaries. Promise<Account> communicates success but says nothing about expected absence or domain rejection; those outcomes may deserve a union or result type rather than an undocumented exception.

Keep expected outcomes in the return typetypescript
type FindAccountResult =
  | { kind: "found"; account: Account }
  | { kind: "not-found" }

async function findAccount(id: AccountId): Promise<FindAccountResult> {
  const row = await repository.find(id)
  return row
    ? { kind: "found", account: Account.from(row) }
    : { kind: "not-found" }
}

Prefer straight-line await when operations depend on each other

await makes a dependent sequence readable and gives try/catch the same shape as synchronous control flow. It does not make the function synchronous, nor does it block the process while network I/O is pending.

Do not mix await and .then chains without a reason. Both use promises, but switching styles inside one operation makes return paths and error handling harder to follow. Always return or await a promise created inside an async function so rejection remains connected to the caller.

A floating promise is work the caller cannot observe. Linters can detect many accidental cases, but deliberate background work still needs an owner, failure reporting and a shutdown policy.

Keep the asynchronous chain attached to its callertypescript
async function activateAccount(id: AccountId): Promise<Account> {
  const account = await accounts.load(id)
  const activated = account.activate()
  await accounts.save(activated)
  await audit.record({ type: "account-activated", id })
  return activated
}

// The caller can now observe every failure in the operation.

Run independent work concurrently, not everything concurrently

Sequential awaits are correct when the second operation needs the first result. Independent calls can begin together and be awaited with Promise.all, reducing total latency to roughly the slowest operation rather than the sum.

Promise.all rejects when one input rejects, but it does not cancel the other operations. Promise.allSettled is appropriate when every outcome must be collected, such as a batch status screen. It should not be used to quietly turn failures into success.

Starting thousands of requests at once can exhaust sockets, rate limits or database connections. Use a worker pool or concurrency-limited mapper for collections whose size is not tightly bounded.

Choose concurrency from dependency and capacitytypescript
const [account, permissions] = await Promise.all([
  accounts.load(id),
  authorisation.permissionsFor(userId),
])

const outcomes = await Promise.allSettled(
  selectedIds.map(id => notifications.send(id)),
)

const failures = outcomes.filter(
  (outcome): outcome is PromiseRejectedResult => outcome.status === "rejected",
)

Preserve error meaning across boundaries

catch receives unknown under strict TypeScript settings because JavaScript can throw any value. Narrow it before reading properties and avoid catch blocks that replace every failure with an empty value.

Expected domain outcomes can be returned as typed values. Infrastructure failures usually remain exceptions until an application boundary logs useful context and translates them into an HTTP or job result. Do not report a database timeout as “account not found” merely because both paths failed to produce an account.

Wrap an error only when the new layer adds meaning, and retain the original cause. Log once at the boundary that owns the operation; logging and rethrowing at every layer produces several copies without more evidence.

Add context without discarding the causetypescript
class AccountLoadError extends Error {
  constructor(readonly accountId: AccountId, cause: unknown) {
    super(`Unable to load account ${accountId}`, { cause })
    this.name = "AccountLoadError"
  }
}

async function load(id: AccountId): Promise<Account> {
  try {
    return await repository.load(id)
  } catch (error: unknown) {
    throw new AccountLoadError(id, error)
  }
}

Cancellation needs cooperation

A promise has no general cancel method. Browser fetch and many modern APIs accept an AbortSignal, which lets the caller communicate that a result is no longer wanted. The callee must pass the signal through every supported dependency for cancellation to have an effect.

Cancellation is useful for superseded searches, disconnected requests, timeouts and application shutdown. It does not undo a remote side effect that already happened, so write operations still need idempotency and explicit status handling.

Distinguish cancellation from an operational failure where the user experience or retry policy differs. Always clean up timers and listeners so cancellation machinery does not itself leak resources.

Pass cancellation from the owner to the I/O boundarytypescript
async function searchAccounts(
  query: string,
  signal: AbortSignal,
): Promise<AccountSummary[]> {
  const response = await fetch(`/api/accounts?q=${encodeURIComponent(query)}`, { signal })
  if (!response.ok) throw new Error(`Search failed: ${response.status}`)
  return parseAccountSummaries(await response.json())
}

const controller = new AbortController()
const request = searchAccounts("mushroom", controller.signal)
controller.abort()
await request

Timeout the operation, not only the wait

Racing an operation against a timer can stop the caller waiting, but it does not automatically stop the losing operation. It may continue consuming a connection or complete a write after the timeout has been reported.

Prefer a dependency with AbortSignal or a native timeout option, and make ownership clear. Clear the timer after completion and preserve whether the failure was timeout, cancellation or a response from the remote system.

A fetch timeout that also aborts the requesttypescript
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), timeoutMs)

  try {
    return await fetch(url, { signal: controller.signal })
  } finally {
    clearTimeout(timeout)
  }
}

Do not hide asynchronous work inside array helpers

forEach ignores the promises returned by an async callback. The outer function can finish before any item completes, and rejections may become unhandled. Use for...of for a deliberate sequence or map plus Promise.all for a small, bounded concurrent set.

The same caution applies to filter and reduce. Their callbacks expect immediate values, so an async predicate does not create an asynchronous filter. Resolve the decisions first, then perform the synchronous collection operation.

Make sequential and concurrent intent visibletypescript
// Sequential: preserve order or protect a constrained dependency
for (const account of accounts) {
  await migrate(account)
}

// Concurrent: safe only for a known, bounded collection
await Promise.all(accounts.map(account => refresh(account)))

// Not awaited by forEach:
// accounts.forEach(async account => migrate(account))

Test outcomes without waiting on real time

An async test must return or await the promise under test. Otherwise the test runner can complete before a later assertion or rejection occurs. Assert both fulfilment and rejection paths, including the error information a caller relies on.

Inject dependencies and clocks so the test can control completion. Deferred promises are useful for proving ordering or concurrency without sleep calls. Fake timers can test timeout policy, but they should be advanced deliberately and restored after the test.

Integration tests still matter where behaviour depends on fetch, database drivers, framework request lifetimes or cancellation support. A unit test with an immediately resolved mock cannot prove that a real boundary handles latency, disconnects or partial failure.

Make the test observe the returned promisetypescript / Vitest
it("reports a failed account lookup", async () => {
  repository.load.mockRejectedValueOnce(new Error("connection refused"))

  await expect(service.load(accountId)).rejects.toMatchObject({
    name: "AccountLoadError",
    accountId,
  })
})

it("waits for the audit record before completing", async () => {
  const auditCompleted = deferred<void>()
  audit.record.mockReturnValueOnce(auditCompleted.promise)

  const activation = service.activate(accountId)
  expect(await isSettled(activation)).toBe(false)

  auditCompleted.resolve()
  await expect(activation).resolves.toMatchObject({ status: "active" })
})

Put policy where the application can see it

Retries, timeouts, concurrency limits and cancellation are application policies, not scattered syntax corrections. Keep them close to the operation that understands idempotency, capacity and the user-facing deadline.

A retry can duplicate a write or amplify an outage. Retry only failures known to be transient, use bounded attempts with delay, and keep the complete operation within a useful deadline. Record enough context to explain which attempt and dependency failed.

Well-structured asynchronous TypeScript is not code with await on every line. It makes ownership visible: who starts the work, who waits, who can cancel it, how much may run at once and where a failure becomes an application result.