Every team starts with the same optimistic assumption: transcription is one HTTP call. Upload a file, receive text, move on. That assumption holds for a fifteen-second voice memo and collapses the first time a customer uploads a ninety-minute webinar, the engine takes four minutes longer than your request timeout, the completion callback arrives twice, and support has to regenerate subtitles three weeks later because nobody kept the timing data.
The mental model that survives production is an assembly line with five distinct stages. Ingestion accepts an upload or a remote media URL, validates the container and size, and records ownership. Normalization extracts a predictable audio stream so every downstream engine sees the same shape of input. Transcription dispatches that audio to a speech-to-text engine, synchronously for very short clips and as a background job for anything longer. Post-processing aligns timestamps, merges speaker turns, applies punctuation and formatting, and generates derivative formats. Serving exposes the result through search, subtitles, summaries, and internal analytics.
NestJS maps onto that shape unusually well. Its module system gives each stage a natural home, dependency injection makes engine swapping practical rather than aspirational, and TypeScript keeps vendor payload shapes from leaking into your domain layer. More importantly, NestJS encourages explicit boundaries, and explicit boundaries are what make a media pipeline debuggable at two in the morning when a customer's recording has been sitting in a processing state for an hour.
Consider a concrete example. A podcast platform adds transcripts to attract search traffic. The first implementation calls a hosted engine directly from the upload controller. It works for three weeks. Then a customer uploads a two-hour interview recorded over a shaky conference bridge, the controller times out after thirty seconds, the browser shows a generic error, and the user retries four times. The team now has four duplicate jobs, no status endpoint, and no idea which one to trust. Every problem in that story disappears when the same work runs through a queue with a normalized status model.
The rest of this guide walks through the decisions that actually matter: how to evaluate engines, how to structure the NestJS service layer, how to handle long-running work, and which mistakes cost teams the most time.
Feature tables published by vendors are close to useless because they compare capabilities rather than constraints. What matters is how a candidate behaves against your specific workload. Score every option against the five criteria below, and write the results down — you will need them when someone proposes switching engines eighteen months from now.
Published accuracy figures come from clean, read speech recorded in quiet rooms. Your media is probably not that. It has overlapping speakers, background music, regional accents, crosstalk, echo from conference rooms, and product names that no general-purpose model will spell correctly. Build an evaluation set of twenty to fifty clips that represent your worst realistic cases, including one file with heavy background noise and one with two people talking over each other. Run each candidate against that set and diff the outputs by hand. Two hours of careful listening beats any leaderboard.
Ask three questions of every provider. How long does a ten-minute clip take to finish? What happens to latency when fifty jobs are submitted at once? Is there a documented concurrency limit per account, and what error do you get when you exceed it? Synchronous endpoints typically cap out at a small number of minutes of audio; beyond that you want a job-based interface with a submit-and-poll or submit-and-callback shape. Streaming transcription is a separate product category that shines for live captions and is awkward for batch archive processing.
Check the unglamorous details. Does the API accept your container formats directly, or must you extract audio first? Does it return word-level timestamps or only segment-level timings? Is speaker diarization included, optional, or unavailable? Can you supply custom vocabulary so brand names, medical terms, and internal jargon come back correctly? Word-level timings matter enormously if you plan karaoke-style captions, precise clip boundaries, or per-word search. If you only need readable paragraphs, segment-level timing is enough and cheaper to store.
Self-hosting an open speech model gives you control over data residency and unit economics at high volume, but it costs GPU capacity, model maintenance, evaluation cycles, and real operational attention. Managed services ship faster and scale elastically. A pragmatic path is to start managed, keep the provider interface deliberately narrow, and reserve the option to move high-volume background processing to a self-hosted model later. Teams that skip the narrow interface discover that "later" quietly turns into a rewrite.
Understand exactly what you are charged for: per minute of audio, per request, per seat, or a blend of several meters. Duration-based pricing punishes silence, which turns silence trimming during normalization into both a quality improvement and a cost improvement. Whatever the model, build a per-tenant usage ledger from day one. Without it, you cannot answer the only question finance will ever ask: which feature is generating the spend?
Write the five criteria into a simple table with a weight for each. Accuracy might carry forty percent, latency twenty, feature coverage twenty, operational cost ten, and billing predictability ten. Fill the table for every candidate, then keep it in the repository next to the adapter code. When a new engine appears, you re-score instead of re-arguing, and the decision takes an afternoon rather than a quarter.
The single most valuable architectural decision is a narrow provider contract. Retries, queues, storage, and analytics all hang off that interface, so get it right before writing a single adapter.
Keep the interface small, unit-agnostic, and free of vendor field names. Normalize every duration to milliseconds at the boundary, and never let a provider-specific status string reach your database.
export interface TranscriptionRequest {
audioUrl: string;
language?: string;
diarize?: boolean;
wordTimestamps?: boolean;
vocabulary?: string[];
callbackUrl?: string;
}
export interface TranscriptWord {
startMs: number;
endMs: number;
text: string;
}
export interface TranscriptSegment {
startMs: number;
endMs: number;
text: string;
speakerId?: string;
words?: TranscriptWord[];
}
export interface TranscriptResult {
providerJobId: string;
language: string;
durationMs: number;
segments: TranscriptSegment[];
}
The provider contract itself has three responsibilities: submit a job, poll for a result, and parse an inbound callback payload into either a finished result or a status update. Three methods are enough. Vendors that require twelve methods are usually asking you to adopt their data model, which is exactly what you want to avoid.
Each vendor becomes a small adapter class registered as a NestJS provider behind an injection token. A resolver picks the adapter per request based on tenant configuration, detected language, or media length. Short clips might go to a fast, inexpensive engine; long multilingual recordings might go to the most accurate one; internal batch work might route to a self-hosted model running on your own hardware. The calling code never learns which path it took, and that invisibility is the point.
Use validation DTOs for inbound HTTP requests, but never reuse vendor response types as your domain model. Vendors rename fields, deprecate response shapes, and change timestamp units between API versions. Everything crossing the adapter boundary gets converted into your own types, with milliseconds as the only timing unit anywhere in the system. When a provider silently switches from seconds to milliseconds, a single normalization function protects every consumer downstream.
Load keys through a dedicated configuration namespace, validate the schema at boot, and refuse to start when something is missing. A missing key discovered during deployment costs a minute; the same key discovered during a customer upload costs trust. Keep all provider calls server-side, never log secrets, and never echo a raw provider error body to a client, because those bodies routinely contain fragments of the original request.
A user uploads a forty-minute recording. The ingest controller validates the container, writes a media row with a pending status, and pushes a job. The worker extracts audio, trims silence, uploads a mono track, generates a signed URL, and calls submit on the adapter. The adapter returns a job identifier, which is stored and also scheduled for a poll in five seconds. Twenty minutes later a signed callback arrives. The handler verifies the signature, detects that the transcript is not yet finalized, fetches the full payload, normalizes it, stores the canonical record, generates VTT, and flips the status to completed. The user sees a notification within a second of that flip. Nothing in that sequence required the front end to know anything about the engine.
Quality is largely decided before the speech engine sees anything. Preprocessing is not optional polish; it is the difference between eighty-eight percent and ninety-five percent word accuracy on messy media.
A worker should extract a mono track at 16 kHz, apply gentle loudness normalization, and trim silence from the head and tail. This reduces payload size, improves recognition consistency across engines, and reduces duration-based spend. Keep the original media untouched so you can reprocess later with different settings when a better engine appears. Store the preprocessing recipe alongside the transcript so results remain reproducible months later, when someone asks why a phrase was transcribed oddly.
Most managed engines fetch media from a URL rather than accepting a file upload. Generate a short-lived signed URL from object storage with an expiry that comfortably exceeds the expected job duration, and scope the signature to that single object. Never expose a bucket publicly to make an integration work; a signed URL plus a private bucket is the only sane pattern. If your storage layer supports it, log every signed URL issuance so you can audit who retrieved which media.
Persist a single canonical record containing segments, timing, language, and speaker identifiers. Generate SRT, VTT, plain text, and JSON on demand rather than storing five copies that drift apart. Subtitle formats share a timestamp structure and differ mainly in header and cue syntax, so the conversion code is trivial once the canonical record exists. Store speaker labels as opaque identifiers mapped to display names in your own tables; never bake a display string into the transcript itself, because display names change when users rename themselves in the product.
Long-form media forces asynchronous design. Polling and webhooks are the two mainstream patterns, and mature systems support both so a missed callback never strands a job.
A Redis-backed queue provides delayed jobs, retries with backoff, concurrency control, and a dead-letter path. Model at least three job types: submit, poll, and finalize. The poll job re-enqueues itself with a growing delay until the provider reports completion, then hands off to finalize, which fetches the result, runs post-processing, and persists derivative formats. Track attempt counts inside the job payload so you can enforce your own ceiling and alert on media that exceeds it. A job that has polled forty times without completing is not a transient blip; it signals a bad submission or a stuck job on the provider side.
Webhooks are faster and cheaper than polling, but they introduce authenticity, ordering, and duplication problems. Verify a signature header with a timing-safe comparison against a shared secret. Assume out-of-order delivery: a completion event can arrive before a started event, so design state transitions to be monotonic and never move a finished transcript back into processing. Assume duplication: providers retry aggressively. Key your handler on the provider job identifier and short-circuit when the transcript is already finalized. Write an explicit test that delivers the same callback twice and asserts a single finalization; it is the cheapest insurance in the entire pipeline.
Submission takes two seconds. Preprocessing takes roughly twenty seconds for a file of that length. The engine returns a job identifier immediately and finishes in about six minutes. Meanwhile your poll job fires at five, fifteen, and forty-five seconds, then every two minutes. The callback lands at six minutes and ten seconds, finalization takes four hundred milliseconds, subtitles render on demand a moment later. Total user-visible latency is under seven minutes, and no HTTP request lived longer than a second.
Expose one normalized status endpoint with four states — pending, processing, completed, failed — plus a human-readable failure reason. Clients should never know which engine processed their file. When you later add a second provider or reroute traffic for cost reasons, the front end should not notice anything at all.
Treating every error identically is the fastest route to a queue clogged with poisoned jobs. Classify failures explicitly before deciding anything about retries.
Set explicit timeouts on every outbound call and keep them shorter than the queue's visibility timeout, otherwise a hung request occupies a worker indefinitely. Log the provider job identifier on every attempt so support can trace one media item from upload to subtitle without guesswork. When a failure is resolved manually, record the action in the audit trail so the next person sees the resolution instead of rediscovering it.
Media files are among the most sensitive objects an application stores, and transcripts are frequently more sensitive than the audio because they are searchable, greppable text.
Every transcript read should pass an authorization check tied to media ownership, not to a guessable identifier. Enforce tenant scoping at the repository layer so a controller that forgets a filter cannot leak another tenant's data. Public sharing, when you add it, should use unguessable tokens with an explicit expiry and a revocation path.
Encrypt at rest and in transit, and define a retention policy before legal asks for one. Some customers will request deletion of both media and transcript; make that a first-class operation rather than a manual database cleanup performed by an engineer with production access. If media leaves your infrastructure for a third-party engine, confirm whether the provider retains content and for how long, then document the answer in your privacy materials. Ambiguity here becomes a sales blocker in regulated industries.
Record who requested a transcription, when, which provider handled it, and which settings were applied. When a customer disputes a transcript, an audit trail turns a week of confusion into a ten-minute investigation. Keep the audit record separate from the transcript so deletion requests can be honored precisely.
A transcript sitting in a table is a cost center. A transcript wired into features is a product, and each of these consumers reads the same canonical record.
Because you normalized early, adding a seventh consumer means writing a new reader, not rebuilding ingestion. That is the payoff of the pipeline approach, and it compounds: every new feature inherits accuracy, timing, and speaker data it did not have to compute.
Transcription pipelines are hard to unit test because they depend on slow external systems. The solution is to test your code, not theirs.
Capture real provider responses as JSON fixtures, including awkward cases: empty segments, a single word, overlapping speakers, and responses with missing timestamps. Run your adapter and post-processing logic against those fixtures on every commit. Add a nightly contract test against a sandbox environment so you learn about breaking API changes before customers do.
Instrument queue depth, job age, success rate per provider, median and p95 transcription latency, retry counts, and cost per minute of processed audio. Alert on queue age rather than queue depth; depth can spike harmlessly during a batch import, but an old job means someone is waiting. Track the ratio of automated finalizations to manual interventions, because that number describes how much human attention the pipeline still consumes.
Add a new engine behind a feature flag and route a small percentage of traffic to it. Compare outputs on identical media before changing defaults. Keep the previous adapter in the codebase for a full billing cycle, because the failures you care about are the ones that only appear at month-end volume.
Sending raw video containers, stereo tracks, and long silent intros to a speech engine is slower, more expensive, and less accurate than sending a normalized mono track. Storing only the final text means you cannot add captions or clip discovery later without reprocessing everything. Ignoring duplicate callbacks guarantees a class of bug that is invisible in staging. Hardcoding one provider everywhere makes testing harder and migration brutal. Logging provider error bodies with request fragments attached is a quiet security problem. Skipping the per-tenant usage ledger means nobody can explain the invoice. Forcing live transcription through a batch queue creates a system that is bad at both jobs.
Base the envelope on media length: a few minutes for short clips, up to a couple of hours for long recordings. Alert when any job exceeds your p95 by a wide margin, then investigate rather than waiting for a support ticket.
Use webhooks when the provider supports them and you can expose a verified public endpoint. Keep polling as a fallback so a missed callback never strands a job. Running both is normal engineering, not redundancy for its own sake.
Prefer engines with automatic language detection, store the detected language per segment, and let users override the setting at submission time when detection is unreliable. Mixed-language recordings are common in interviews and conference talks, so test that case explicitly rather than assuming it works.
Only if you plan karaoke captions, precise clip boundaries, or per-word search. They inflate payload size, so store them in a separate table and load them on demand when your canonical record only needs segment timings.
Live transcription is a separate architecture built on persistent streams and partial results. Do not force it through a batch job queue. Give it its own module, its own contract, and its own scaling profile.
Keep the audio pipeline in one worker service with a versioned recipe. When you change the recipe, store the version with each transcript so old results remain interpretable. Recompute only when the change is significant enough to justify the spend.
Define a narrow provider interface. Normalize all timing to milliseconds. Preprocess audio before submission. Run long jobs through a queue with explicit attempt ceilings. Verify and deduplicate callbacks. Classify failures before retrying. Encrypt and scope everything per tenant. Derive every user-facing format from one canonical transcript. Do those things and switching speech engines becomes a week of adapter work instead of a quarter of rewrites.

Alexander
Vidu Q4 alternatives compared: Kling 4.0, Seedance 2.5, Wan 3.0, and Runway Gen-4 — 4K support, input modes, durations, audio, and what each costs in credits on Domer.
The top 4K AI image generators on Domer compared: Flux 3 Klein, Nano Banana 2.1, GPT Image 2.5, GPT Image 2, and Nano Banana Pro — 4K credit costs, reference limits, and speed.
Nano Banana 2.1 vs GPT Image 2.5 Flare compared: credit costs per resolution, aspect ratios, reference images, text rendering, and which tasks fit each model.