Audio AI · Distributed Systems Case study
ScribeFlow
An event-driven audio transcription platform with timestamped transcripts and JSON, SRT and VTT exports.
Audio AI · Distributed Systems
ScribeFlow ScribeFlow
System Design & End-to-End Engineering
System map · Processing pipeline
Reliability model
- Never silently lost A crash redelivers; a duplicate is recognised and skipped
- The queue is not the database PostgreSQL holds job state; RabbitMQ holds a transient signal
Job path
Accept
Validated, stored, 202 with an id
Queue
RabbitMQ carries the signal only
Transcribe
Normalise, segment, transcribe
Commit
Transcript into PostgreSQL
Acknowledge
Only after the commit
Retry with backoff, then dead-letter
Reliability model
- Ack-after-commit
- Idempotent consumers
- Content-hash deduplication
- Dead-letter parking
Engineering
NestJS · Next.js · Python · PostgreSQL · RabbitMQ · FFmpeg · faster-whisper
Independent system build · Architecture documented
- Role
- System Design & End-to-End Engineering
- Disclosure
- Independent system build · Architecture documented
The problem
What the system had to solve.
Transcribing audio takes tens of seconds to minutes and holds a CPU while it runs. Doing that inside an HTTP request means clients time out, the process cannot be restarted without losing work in flight, and one large upload degrades every other request being served.
The system had to accept uploads, survive worker crashes and broker restarts without losing an accepted job, retry what can be retried, and report progress the whole time.
What I owned
The responsibility behind the work.
Designed and built the whole system: the asynchronous API, the job model, the queue topology, the Python transcription workers, the failure handling and the web client.
Designed and built the asynchronous upload, queue and worker architecture: a NestJS API, PostgreSQL job state, RabbitMQ delivery, Python transcription workers, retry and dead-letter handling, and a Next.js client.
Engineering approach
How it was built.
The API accepts, records and publishes, then returns an identifier; it never reads the queue and never transcribes. Job state lives in PostgreSQL as the source of truth while RabbitMQ carries transient work signals. Workers acknowledge only after the transcript is committed, check job status before starting so redelivery is safe, and fail into a retry queue with growing delays before dead-lettering.
Job path
- Accept
- Queue
- Transcribe
- Commit
- Acknowledge
The API never does heavy work
One rule the whole design follows from.
Transcribing audio takes tens of seconds to minutes and pins a CPU while it runs. An HTTP request cannot hold that open: the client times out, the server cannot be restarted without losing work in flight, and one slow upload degrades every other request on the process.
So the API accepts the file, records the job, publishes a message and returns immediately with an identifier. Everything expensive happens somewhere else, and the client polls for the result.
Where the work happens
API — fast path only
- Validate type and size
- Stream the file to shared storage
- Insert the job row
- Publish one message
- Return 202 with an id
Worker — all the cost
- Consume one message at a time
- Normalise audio
- Run the transcription model
- Write the transcript
- Acknowledge the message
The API never reads the queue. It answers every status request from the database, which means queue depth has no effect on how fast a client can be told what is happening.
Upload to transcript
The whole path, end to end.
Request lifecycle
- Upload accepted Validated and streamed to shared storage
- Job row inserted Status queued
- Message published Persistent, publisher-confirmed
- 202 returned with the job id
- Worker consumes and transcribes
- Transcript committed Status completed
- Client polls and receives the result
The message is published as persistent and with publisher confirms, so an accepted upload is not lost by a broker restart between the API returning 202 and the worker picking the job up.
The queue is not the database
Where job state actually lives.
A queue is a work signal, not a record. Messages are transient, can be redelivered, and disappear once acknowledged. None of that is acceptable for "what is the status of my transcription".
Job state lives in PostgreSQL and is the single source of truth. The message carries only enough to find the work: an identifier and a file path.
Two stores, two jobs
PostgreSQL — source of truth
- Job status and progress
- Attempt count
- The transcript itself, as JSONB
- Content hash for deduplication
- Survives everything
RabbitMQ — transient signal
- Job id and file path
- Attempt number
- Delivery and redelivery
- Retry scheduling and dead-lettering
- Discarded on acknowledgement
Deduplication by content
The same audio twice is not two jobs.
Before a job is created, the uploaded file is hashed as it is read from disk. If a completed job already exists for that hash, the API returns that job immediately and removes the temporary file. No message is published, and no CPU is spent transcribing something already transcribed.
Upload path
- Upload received
- Validate type and size
- Hash the file contents
- Look up a completed job with that hash
- Already transcribed Return the existing job
- Temporary file removed
- No work published
- New content Create the job
- Row inserted
- Message published
Delivery guarantees
Acknowledge only what is actually finished.
The worker takes one message at a time and acknowledges manually. The acknowledgement is sent after the transcript has been committed to the database — never before. That ordering is the difference between at-least-once delivery being safe and being a source of silent data loss.
Acknowledgement ordering
- Message delivered
- Job marked processing
- Audio normalised and transcribed
- Transcript committed to PostgreSQL
- Message acknowledged
A prefetch of one means a worker holds exactly one job. Work is distributed by capacity rather than dealt out in advance, so a slow worker never sits on a queue of jobs another worker could have finished.
Retries, backoff and dead letters
What happens when a job fails.
A handled failure returns the job to the queued state and schedules a retry with a growing delay. After three attempts the job is marked failed and the message is parked in a dead-letter queue, where it can be inspected rather than lost or retried forever.
Retry schedule
- Attempt 1 fails
- Wait 10 seconds
- Attempt 2 fails
- Wait 30 seconds
- Attempt 3 fails
- Dead-letter queue
Job state transitions
- Upload accepted
queued
- Worker starts an attempt
processing, attempt count incremented, progress reset
- Transcript committed
completed — terminal
- Handled failure
- Retries remaining
back to queued after a delay
- Handled failure
- Retries exhausted
failed, message parked in the dead-letter queue
- Worker crashes before acknowledging
broker redelivers; no state change was committed
Redelivery is normal
Designing for the message arriving twice.
At-least-once delivery means duplicates are not an error case, they are an expected one. A worker crash mid-transcription, a network partition, or a broker restart all produce a redelivered message for work that may or may not have finished.
Every attempt starts with a question
- Message delivered
- Read the job status from PostgreSQL
- Already completed? Acknowledge and skip
- Otherwise mark processing and continue
Progress updates are guarded on the job still being in the processing state, so a late write from an abandoned attempt cannot move a job that another worker has already completed.
Inside the worker
Turning arbitrary audio into text.
Uploaded audio is whatever the user had: a phone recording, an export from a meeting tool, a file with a container the model has never seen. Normalisation happens before transcription so the model receives one predictable format regardless of what arrived.
Processing pipeline
- Probe the file Reject what is not really audio
- Normalise 16 kHz mono, one predictable format
- Voice activity detection Skip silence
- Chunk long audio Beyond ten minutes
- Transcribe with timestamps
- Commit transcript as JSONB
The model is baked into the worker image, so the runtime is fully offline. No audio leaves the deployment, and there is no third-party transcription API to be rate-limited by, billed by, or broken by.
What the client sees
Progress without holding a connection.
The client polls a single endpoint that returns status, progress and — once finished — the transcript itself. Progress writes from the worker are throttled, so a long job reports movement without turning a transcription into a write-heavy workload.
Returned with every poll
- Status
- Progress
- Attempt count
- Detected language
- Duration
- Transcript when complete
Export formats
- JSON with per-segment timestamps
- SRT subtitles
- VTT subtitles
Per-segment timestamps are kept in the stored transcript rather than being derived on export, so subtitle formats are a projection of the same data rather than a second transcription path.
Engineering scope
What I worked on.
- Asynchronous transcription API
- RabbitMQ retries and dead-letter queues
- Idempotent PostgreSQL job state
- FFmpeg normalization and faster-whisper processing
- Timestamped transcript and subtitle exports
Why these decisions mattered
The trade-offs behind the architecture.
Asynchronous by default
Postgres as source of truth
Acknowledge after commit
Status check before work
Deduplicate on content hash
Model in the image
Impact
What the design guarantees.
An accepted upload is never silently lost: a worker crash redelivers, a duplicate delivery is recognised and skipped, a repeated file is deduplicated by content hash, and a permanently failing job is parked for inspection rather than retried forever.
Technology
The technical surface.
- NestJS
- Next.js
- Python
- PostgreSQL
- RabbitMQ
- FFmpeg
- faster-whisper