Skip to content

Reliable Multi-Stage Async Workflows in TypeScript

Introduction

Saving a record is easy when it is one request.

A user may create a draft, upload several files, synchronize their metadata, and finally publish the record. Each step can take a different amount of time and fail in a different way. The UI still needs useful progress, the network needs a cancellation strategy, and support needs to know how far the operation got.

Putting every promise inside one large try/catch hides those details. A generic "Save failed" message cannot tell you whether no remote data exists, a draft exists without files, or all files uploaded but finalization failed.

In this article, we will build a small TypeScript stage runner that gives a multi-step workflow:

  • named progress events
  • stage-specific timeout errors
  • explicit abortable and non-abortable behavior
  • a record of completed stages
  • dependency injection for focused tests
  • deterministic timeout tests without waiting on the real clock

The example uses a generic record-and-files flow. The same pattern works for checkout, onboarding, report generation, imports, media processing, and any operation that crosses several async boundaries.

Model the Workflow Before Running It

Start by naming the stages. A string union is simple, searchable, and difficult to mistype:

ts
export type SaveStage =
  | 'create_record'
  | 'upload_files'
  | 'finalize_record';

export interface StageCallbacks<
  S extends string,
  P extends object = Record<string, never>,
> {
  onStageStart?: (stage: S) => void;
  onStageComplete?: (stage: S) => void;
  onStageError?: (
    stage: S,
    error: unknown,
    partialOutputs: Readonly<P>,
  ) => void;
}

These events are more useful than a numeric percentage. The UI can translate upload_files into "Uploading attachments," analytics can measure duration by stage, and logs can identify the failing boundary.

Build a Timeout Primitive with Honest Semantics

There are two different meanings behind "this operation timed out":

  1. Stop waiting: your code rejects after a deadline, but the underlying work may continue.
  2. Request cancellation: your code rejects and also signals the underlying operation to stop.

JavaScript promises do not have built-in cancellation. Promise.race() can stop your caller from waiting, but it cannot stop the losing promise. AbortController only helps when the underlying API actually observes its signal.

The following helper supports both behaviors and keeps the distinction explicit:

ts
export type ScheduleTimeout = (
  callback: () => void,
  timeoutMs: number,
) => () => void;

export const systemScheduleTimeout: ScheduleTimeout = (callback, timeoutMs) => {
  const timeoutId = setTimeout(callback, timeoutMs);
  return () => clearTimeout(timeoutId);
};

export class StageTimeoutError<S extends string> extends Error {
  constructor(
    public readonly stage: S,
    public readonly timeoutMs: number,
  ) {
    super(`Stage ${stage} timed out after ${timeoutMs}ms`);
    this.name = 'StageTimeoutError';
  }
}

export async function withStageTimeout<T, S extends string>(params: {
  stage: S;
  timeoutMs: number;
  abortable: boolean;
  run: (signal?: AbortSignal) => Promise<T>;
  scheduleTimeout?: ScheduleTimeout;
}): Promise<T> {
  const {
    stage,
    timeoutMs,
    abortable,
    run,
    scheduleTimeout = systemScheduleTimeout,
  } = params;

  const controller = abortable && typeof AbortController === 'function'
    ? new AbortController()
    : null;

  let rejectTimeout: (error: StageTimeoutError<S>) => void = () => {};
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
    rejectTimeout = reject;
  });

  const cancelTimeout = scheduleTimeout(() => {
    // Reject first so callers consistently receive the timeout error even if
    // aborting causes the operation promise to reject synchronously.
    rejectTimeout(new StageTimeoutError(stage, timeoutMs));
    controller?.abort();
  }, timeoutMs);

  try {
    return await Promise.race([
      run(controller?.signal),
      timeoutPromise,
    ]);
  } finally {
    cancelTimeout();
  }
}

For an abortable stage, pass the signal all the way to an API such as fetch:

ts
run: (signal) => fetch('/records', {
  method: 'POST',
  body: JSON.stringify(input),
  signal,
})

Do not mark a stage abortable merely because the runner can create a signal. If the operation ignores that signal, the timeout only stops waiting. The work may still finish and produce a side effect later.

Timeout Is Not Rollback

A timeout tells you the caller stopped waiting. It does not prove that a remote write failed. Use idempotency keys or a reconciliation read before retrying a stage that may have reached the server.

Wrap Each Stage and Preserve Partial Completion

The timeout helper handles one promise. The stage runner adds lifecycle events and failure context:

ts
export class StagedWorkflowError<
  S extends string,
  P extends object,
> extends Error {
  constructor(
    public readonly failedStage: S,
    public readonly completedStages: readonly S[],
    public readonly partialOutputs: Readonly<P>,
    public readonly cause: unknown,
  ) {
    super(`Workflow failed during ${failedStage}`);
    this.name = 'StagedWorkflowError';
  }
}

export async function runStage<
  T,
  S extends string,
  P extends object,
>(params: {
  stage: S;
  completedStages: S[];
  partialOutputs: Readonly<P>;
  timeoutMs: number;
  abortable?: boolean;
  run: (signal?: AbortSignal) => Promise<T>;
  callbacks?: StageCallbacks<S, P>;
  scheduleTimeout?: ScheduleTimeout;
}): Promise<T> {
  const {
    stage,
    completedStages,
    partialOutputs,
    timeoutMs,
    abortable = false,
    run,
    callbacks = {},
    scheduleTimeout,
  } = params;

  callbacks.onStageStart?.(stage);

  try {
    const result = await withStageTimeout({
      stage,
      timeoutMs,
      abortable,
      run,
      scheduleTimeout,
    });

    completedStages.push(stage);
    callbacks.onStageComplete?.(stage);
    return result;
  } catch (error) {
    const outputSnapshot = Object.freeze({ ...partialOutputs });
    callbacks.onStageError?.(stage, error, outputSnapshot);
    throw new StagedWorkflowError(
      stage,
      [...completedStages],
      outputSnapshot,
      error,
    );
  }
}

A stage is recorded only after it resolves. If upload_files fails, the error can still show that create_record completed and expose the draft created by that stage. Copying the arrays and partial-output object prevents later top-level mutations from changing the historical failure state.

Lifecycle callbacks should be lightweight. If a progress callback can throw, isolate that behavior inside the callback so a rendering or analytics problem does not turn a successful remote operation into a failed save.

Compose the Real Save Flow

Dependency injection keeps the orchestrator independent from a specific HTTP client, database, or storage SDK:

ts
interface UploadedFile {
  key: string;
  url: string;
}

interface FileToUpload {
  name: string;
  bytes: Uint8Array;
}

export interface SavePartialOutputs {
  readonly record?: { readonly id: string };
  readonly uploadedFiles?: readonly UploadedFile[];
}

interface SaveDependencies {
  createDraft: (
    input: { title: string },
    signal?: AbortSignal,
  ) => Promise<{ id: string }>;
  uploadFiles: (
    recordId: string,
    files: FileToUpload[],
  ) => Promise<UploadedFile[]>;
  finalizeRecord: (
    recordId: string,
    files: UploadedFile[],
    signal?: AbortSignal,
  ) => Promise<void>;
}

export async function saveRecordWithFiles(params: {
  title: string;
  files: FileToUpload[];
  dependencies: SaveDependencies;
  callbacks?: StageCallbacks<SaveStage, SavePartialOutputs>;
  timeoutMs?: number;
}) {
  const {
    title,
    files,
    dependencies,
    callbacks,
    timeoutMs = 20_000,
  } = params;

  const completedStages: SaveStage[] = [];
  const initialOutputs: SavePartialOutputs = {};

  const record = await runStage({
    stage: 'create_record',
    completedStages,
    partialOutputs: initialOutputs,
    timeoutMs,
    abortable: true,
    callbacks,
    run: (signal) => dependencies.createDraft({ title }, signal),
  });

  const createdOutputs: SavePartialOutputs = { record };
  const uploadedFiles = files.length > 0
    ? await runStage({
        stage: 'upload_files',
        completedStages,
        partialOutputs: createdOutputs,
        timeoutMs,
        callbacks,
        run: () => dependencies.uploadFiles(record.id, files),
      })
    : [];

  const uploadedOutputs: SavePartialOutputs = {
    record,
    uploadedFiles: [...uploadedFiles],
  };

  await runStage({
    stage: 'finalize_record',
    completedStages,
    partialOutputs: uploadedOutputs,
    timeoutMs,
    abortable: true,
    callbacks,
    run: (signal) => dependencies.finalizeRecord(
      record.id,
      uploadedFiles,
      signal,
    ),
  });

  return { record, uploadedFiles, completedStages };
}

Here the network stages are abortable because their dependencies accept a signal. The upload adapter is intentionally marked non-abortable. Its timeout ends the UI wait, but the adapter may continue. If your storage SDK supports cancellation, change the dependency contract to accept a signal and opt in.

This flow is sequential because later stages need earlier results. Independent uploads can still run concurrently inside uploadFiles, where concurrency limits and per-file errors can be managed without making the top-level workflow harder to understand.

Design the Failure State, Not Just the Success State

The completed-stage list is operational state, not a rollback mechanism.

Suppose finalization fails after the draft and uploads complete. The error and callback receive a typed partialOutputs snapshot, so the app can retain the record ID and uploaded-file context for a targeted retry.

Partial outputs may contain signed URLs or other sensitive data. Do not serialize the whole error directly. A sanitized support log might contain:

json
{
  "failedStage": "finalize_record",
  "completedStages": ["create_record", "upload_files"],
  "recordId": "record-1",
  "uploadedFileKeys": ["report.txt"]
}

That is much more actionable than a stack trace by itself.

For production workflows, pair stages with server-side safeguards:

  • use an idempotency key when creating a record
  • create records in a draft or processing state
  • make finalization safe to repeat
  • persist stable file keys instead of generating new ones on every retry
  • add cleanup or reconciliation for abandoned drafts and orphaned uploads
  • retry only stages whose side effects are understood

Automatic retries are dangerous for non-idempotent operations. If a timeout occurs after the server committed a write, blindly rerunning the request may create a duplicate. First query by the idempotency key or stable record ID.

Turn Stage Events into Useful UI State

The callbacks create a clean boundary between orchestration and presentation. A screen does not need to understand the promises inside the workflow; it only needs the active stage and the final result:

ts
let activeStage: SaveStage | null = null;
let lastPartialOutputs: Readonly<SavePartialOutputs> = {};

const callbacks: StageCallbacks<SaveStage, SavePartialOutputs> = {
  onStageStart: (stage) => {
    activeStage = stage;
    renderProgress(stage);
  },
  onStageComplete: () => {
    activeStage = null;
  },
  onStageError: (stage, error, partialOutputs) => {
    activeStage = null;
    lastPartialOutputs = partialOutputs;
    reportSaveFailure({
      stage,
      error,
      recordId: partialOutputs.record?.id,
      uploadedFileKeys: partialOutputs.uploadedFiles?.map(
        (file) => file.key,
      ),
    });
  },
};

If finalization fails, lastPartialOutputs.record?.id and lastPartialOutputs.uploadedFiles are available to the retry UI. For a non-abortable upload timeout, the snapshot intentionally contains only the record: the still-running upload has not produced a confirmed result.

Use stage names as state, not as text shown directly to users. A presentation layer can map create_record to "Preparing your record" and upload_files to "Uploading attachments," then localize those labels normally.

Avoid promising a precise percentage unless every stage has measurable work. A spinner plus a truthful stage label is usually better than progress that jumps from 10% to 90% or remains stuck near completion. Also disable duplicate submissions while the workflow is active, but keep a cancel button only when cancelling has defined semantics. Closing a modal is not the same as stopping remote work.

Test Ordering and Partial Completion

Injected dependencies make the happy path a pure orchestration test:

ts
import assert from 'node:assert/strict';
import test from 'node:test';

test('runs stages in order and reports completed work', async () => {
  const calls: string[] = [];

  const result = await saveRecordWithFiles({
    title: 'Quarterly report',
    files: [{
      name: 'report.txt',
      bytes: new TextEncoder().encode('data'),
    }],
    dependencies: {
      createDraft: async () => {
        calls.push('create');
        return { id: 'record-1' };
      },
      uploadFiles: async () => {
        calls.push('upload');
        return [{ key: 'report.txt', url: '/files/report.txt' }];
      },
      finalizeRecord: async () => {
        calls.push('finalize');
      },
    },
  });

  assert.deepEqual(calls, ['create', 'upload', 'finalize']);
  assert.deepEqual(result.completedStages, [
    'create_record',
    'upload_files',
    'finalize_record',
  ]);
});

Test the recovery contract directly by failing finalization after the earlier stages succeed:

ts
test('exposes partial outputs when finalization fails', async () => {
  const expectedOutputs: SavePartialOutputs = {
    record: { id: 'record-1' },
    uploadedFiles: [{
      key: 'report.txt',
      url: '/files/report.txt',
    }],
  };
  let callbackOutputs: Readonly<SavePartialOutputs> | undefined;

  const promise = saveRecordWithFiles({
    title: 'Quarterly report',
    files: [{
      name: 'report.txt',
      bytes: new TextEncoder().encode('data'),
    }],
    callbacks: {
      onStageError: (_stage, _error, partialOutputs) => {
        callbackOutputs = partialOutputs;
      },
    },
    dependencies: {
      createDraft: async () => ({ id: 'record-1' }),
      uploadFiles: async () => [{
        key: 'report.txt',
        url: '/files/report.txt',
      }],
      finalizeRecord: async () => {
        throw new Error('finalization failed');
      },
    },
  });

  await assert.rejects(promise, (error: unknown) => {
    assert.ok(error instanceof StagedWorkflowError);
    assert.equal(error.failedStage, 'finalize_record');
    assert.deepEqual(error.completedStages, [
      'create_record',
      'upload_files',
    ]);
    assert.deepEqual(error.partialOutputs, expectedOutputs);
    return true;
  });

  assert.deepEqual(callbackOutputs, expectedOutputs);
});

Add a companion test where uploadFiles rejects. Assert that failedStage is upload_files, completedStages contains only create_record, partialOutputs contains the record but no uploaded files, and finalizeRecord never runs.

Test Timeouts Without Sleeping

Tests that wait 10 or 50 milliseconds can still be flaky under a busy CI runner. Because the timeout scheduler is injectable, the test can fire it manually:

ts
test('aborts an abortable stage and keeps timeout context', async () => {
  let fireTimeout: () => void = () => {};
  let wasAborted = false;

  const scheduleTimeout: ScheduleTimeout = (callback) => {
    fireTimeout = callback;
    return () => {};
  };

  const promise = runStage({
    stage: 'create_record' as SaveStage,
    completedStages: [],
    partialOutputs: {},
    timeoutMs: 20_000,
    abortable: true,
    scheduleTimeout,
    run: (signal) => new Promise((_resolve, reject) => {
      signal?.addEventListener('abort', () => {
        wasAborted = true;
        reject(new Error('request aborted'));
      });
    }),
  });

  fireTimeout();

  await assert.rejects(promise, (error: unknown) => {
    assert.ok(error instanceof StagedWorkflowError);
    assert.equal(error.failedStage, 'create_record');
    assert.deepEqual(error.completedStages, []);
    assert.ok(error.cause instanceof StageTimeoutError);
    return true;
  });

  assert.equal(wasAborted, true);
});

No real clock advances, so the test is fast and deterministic. A companion non-abortable test should confirm that the caller receives a timeout while documenting that the unresolved work is not cancelled.

Conclusion

A reliable multi-stage workflow makes its boundaries visible. Named stages explain progress, timeout errors identify the stalled operation, AbortSignal expresses cancellation where the dependency supports it, and completed-stage history plus typed partial outputs make partial failure actionable.

The runner is intentionally small. The important design work is around it: honest cancellation semantics, idempotent server operations, deliberate retry rules, and deterministic tests. With those pieces in place, a complicated save flow becomes a sequence you can observe, explain, and recover.