Technology Aug 27, 2026 · 6 min read

Building a Reliable AI Transcription Pipeline on Cloudflare Workers

I recently shipped HiTranscript, a web app that turns public video URLs and local media uploads into searchable transcripts and subtitle files. The transcription model was not the hardest part. The hard part was building a pipeline that stays correct when uploads are large, requests are retried, c...

DE
DEV Community
by Bill King
Building a Reliable AI Transcription Pipeline on Cloudflare Workers

I recently shipped HiTranscript, a web app that turns public video URLs and local media uploads into searchable transcripts and subtitle files.

The transcription model was not the hardest part.

The hard part was building a pipeline that stays correct when uploads are large, requests are retried, callbacks arrive twice, a batch partially fails, or a deployment needs to be rolled back.

This post covers the architecture patterns that made the system more reliable: explicit job states, durable media handoffs, idempotent callbacks, item-level batch tracking, and a single normalized timeline for every output format.

The architecture at a glance

The application uses TanStack Start and TypeScript for the web layer, PostgreSQL for durable task state, and Cloudflare Workers, Queues, and R2 for orchestration and media storage.

Browser
  |
  v
TanStack Start API
  |
  +--> PostgreSQL task record
  |
  +--> private R2 object
  |
  v
Cloudflare Queue
  |
  v
media preparation / transcription worker
  |
  v
signed callback
  |
  v
normalized word timeline
  |
  +--> readable transcript
  +--> subtitle cues
  +--> TXT / DOCX / PDF
  +--> SRT / WebVTT

The important design decision is that the HTTP request does not try to finish the transcription. It only validates the request, persists the intent, and creates a durable handoff.

1. Treat job states as a public contract

A boolean such as isProcessing is not enough for a media pipeline.

A real task can be waiting for media, preparing media, queued for transcription, actively processing, completed, or failed. Each state has different retry and UI behavior.

A simplified state model looks like this:

type TranscriptStatus =
  | "awaiting_media"
  | "media_preparing"
  | "queued"
  | "processing"
  | "completed"
  | "failed";

The value of explicit states is not the union type itself. The value is being able to define valid transitions.

For example:

  • awaiting_media can move to media_preparing or failed
  • media_preparing can move to queued or failed
  • queued can move to processing or failed
  • completed and failed are terminal

When every write checks the previous state, stale workers cannot move a finished task backward.

2. Use durable handoffs for large media

Passing large audio or video bodies through several HTTP requests creates unnecessary failure points.

The safer pattern is:

  1. Create a task record.
  2. Upload the media to private object storage.
  3. Put a small message on the queue containing the task ID and object key.
  4. Let the worker fetch the object when capacity is available.
  5. Delete or retain the object according to an explicit retention policy.

The queue message should describe work, not carry the work itself.

This also keeps the web process responsive. The browser can display upload progress, while the backend independently reports preparation and transcription progress.

3. Design every callback for duplicate delivery

Retries are normal in distributed systems. A callback may arrive twice because a worker timed out after completing the request, a queue retried the message, or the provider repeated a webhook.

The callback handler therefore has to be idempotent.

A simplified version of the rule is:

async function completeTask(input: CompletionPayload) {
  verifySignature(input);

  const task = await findTask(input.taskId);

  if (task.status === "completed") {
    return task;
  }

  return database.transaction(async (tx) => {
    const updated = await tx.updateTaskWhereStatus({
      taskId: input.taskId,
      expected: ["queued", "processing"],
      next: "completed",
      result: normalizeResult(input.result),
    });

    if (!updated) {
      return findTask(input.taskId);
    }

    await settleBillingOnce(tx, input.taskId);
    return updated;
  });
}

The database transition, result persistence, and billing settlement belong to one consistency boundary. A duplicate callback should return the existing result instead of charging twice or creating a second output.

Signed callbacks are equally important. Idempotency prevents accidental duplication; signature verification prevents unauthorized state changes.

4. A batch is not just a bigger loop

It is tempting to model a batch as one task containing an array of URLs. That becomes painful when one item fails and the other 49 succeed.

A more useful model is:

  • one batch record for ownership and aggregate status
  • one item record per source
  • independent state and error information for every item
  • an aggregate state derived from the items

This makes partially_completed a first-class outcome rather than an exception.

It also improves fairness. A scheduler can take a capacity snapshot and dispatch work across batches instead of letting one large batch block every single-item request.

5. Preserve word timing as primary data

A transcript paragraph, an SRT file, and a short-form caption layout are different views of the same timing data.

Instead of storing only a large text blob, the pipeline keeps a normalized word timeline:

type TimedWord = {
  text: string;
  startMs: number;
  endMs: number;
  speakerId?: string;
};

From that timeline, the application can derive:

  • readable paragraphs
  • speaker-aware sections
  • standard subtitle cues
  • shorter social caption cues
  • search matches
  • TXT, document, and timed subtitle exports

This avoids running transcription again when the user changes an output option. It also keeps every view aligned to the same source data.

6. Failure handling is part of the product

The internal error may say that a decoder failed, a queue exhausted its retries, or an upstream service rejected a media file. That detail is useful in logs but often harmful in the UI.

The public contract should expose stable, actionable categories such as:

  • unsupported source
  • inaccessible or private media
  • no audio track
  • file too large
  • transcription failed
  • processing timed out

Internally, retain the detailed diagnostic code and attempt history. Externally, show a message the user can act on.

This separation also lets you change providers without changing the product's error language.

7. Deploy the exact version you tested

For this kind of pipeline, deployment safety matters as much as code correctness.

My preferred release flow is:

  1. Build and upload an immutable Worker version with zero production traffic.
  2. Validate the changed routes against that exact version.
  3. Record the source commit and Worker version ID together.
  4. Promote the same version without rebuilding.
  5. Run focused production smoke checks.
  6. Roll back to the previous Worker version if the code is unhealthy.

Rebuilding between validation and promotion breaks the evidence chain. The artifact that reaches users should be the artifact that was tested.

What I would improve next

The next reliability gains are less about adding more providers and more about strengthening the boundaries:

  • provider-neutral result validation
  • better timestamp-density checks
  • queue age and stuck-task observability
  • automated retention verification
  • multilingual and long-audio evaluation sets
  • cost tracking at the task level

A successful provider response is not the same as a usable transcript. Output shape, timestamp coverage, language behavior, and retry semantics all need validation.

Final takeaway

An AI transcription product is a distributed media system before it is an AI demo.

The durable design comes from treating state transitions, storage handoffs, callbacks, billing, and deployment artifacts as explicit contracts. Once those boundaries are reliable, switching models or adding output formats becomes much less risky.

If you are building a similar workflow, I would start with the state machine and idempotency rules before optimizing model latency. Those two decisions will shape almost every failure you have to handle later.

What has been the hardest reliability problem in your own asynchronous pipeline?

DE
Source

This article was originally published by DEV Community and written by Bill King.

Read original article on DEV Community
Back to Discover

Reading List