
Caching Transcription Results: Dedup and Idempotency
Summarize this article with:
Transcription is deterministic: the same audio file through the same engine and model always returns the same transcript. Caching the result with a content-hash key and a param hash eliminates repeated API charges for the same content. The storage cost of a cached transcript is so small that a single avoided re-transcription pays for storing that result for years. This guide covers the idempotency model, cache key construction, Postgres and Redis storage patterns, invalidation on model upgrades, and the arithmetic that shows whether caching is worth building.
Transcription is deterministic. The same audio file sent through the same engine and model produces the same transcript every time. Yet most production systems re-transcribe the same files repeatedly, paying the transcription API on every call. Caching treats that determinism as an asset.
This guide is the production caching pattern: content-hash keys for idempotent dedup, Postgres and Redis storage, invalidation on model upgrades, and the arithmetic that shows how quickly caching pays for itself.
Why Idempotency Is the Foundation
Before thinking about Redis or Postgres, think about what makes caching correct here: transcription is a pure function of (audio content, engine, model, params). Same inputs, same output, always. That property is called idempotency in the broader sense: repeating the operation produces no new information.
The cache is just the mechanism for exploiting it. Build the mechanism right and every re-upload, every backfill run, every "regenerate" button that does not need to regenerate becomes a free hit.
Four patterns that waste money on uncached pipelines:
- A user uploads the same interview twice. Re-transcribed twice.
- A "regenerate" button calls the API instead of returning the stored result.
- A backfill script reprocesses files that already have transcripts.
- A feature deploys that re-transcribes a corpus when only downstream processing changed.
Each wastes one API call. Aggregate across thousands of users and the bill compounds. We have observed re-transcription rates of 25 to 45% on uncached pipelines, though your actual rate depends on your workflow.
Content-Hash Dedup: The Cache Key
The right cache key is a SHA-256 hash of the audio file content, combined with a hash of the engine and model parameters:
function cacheKey(audioBuffer, opts) {
const audioHash = crypto.createHash('sha256').update(audioBuffer).digest('hex');
const paramHash = crypto.createHash('sha256')
.update(JSON.stringify({ engine: opts.engine, language: opts.language, model: opts.model }))
.digest('hex')
.slice(0, 16);
return `transcript:${audioHash}:${paramHash}`;
}
The audio hash is the content identity. The param hash distinguishes "transcribed with Deepgram Nova-3 English" from "transcribed with gpt-4o-transcribe." Both parts must match for a cache hit.
Why not use filename? Filenames are metadata, not identity. interview.mp3 and interview_final.mp3 may be the same file. Two different files can share a name. Content hash resolves both cases correctly.
Hashing Large Files Without Loading Them in Memory
For multi-hundred-MB audio files, loading the full buffer to hash it is wasteful. Stream the hash instead:
import { createReadStream } from 'fs';
import { createHash } from 'crypto';
function hashFile(path) {
return new Promise((resolve, reject) => {
const hash = createHash('sha256');
const stream = createReadStream(path);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
This reads in chunks and updates the hash incrementally, keeping memory flat regardless of file size.
Storage: Postgres for Persistence, Redis for the Hot Path
Postgres as the Primary Store
Postgres is the right default. Transcripts are accessed infrequently and storage is cheap. The schema:
CREATE TABLE transcript_cache (
cache_key TEXT PRIMARY KEY,
audio_hash TEXT NOT NULL,
engine TEXT NOT NULL,
language TEXT NOT NULL,
transcript JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_accessed TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audio_hash ON transcript_cache (audio_hash);
Lookup with access tracking in a single query:
async function getCached(key) {
const row = await db.query(
`UPDATE transcript_cache SET last_accessed = NOW() WHERE cache_key = $1 RETURNING transcript`,
[key]
);
return row.rows[0]?.transcript;
}
The UPDATE ... RETURNING pattern updates the access timestamp and returns the row in one round trip.
Redis as a Hot-Path Layer
If a small set of transcripts is accessed very frequently (recent recordings, popular shared content), Redis reduces Postgres load and brings lookups to sub-millisecond:
async function getCached(key) {
const redisResult = await redis.get(key);
if (redisResult) return JSON.parse(redisResult);
const pgResult = await db.query(
'SELECT transcript FROM transcript_cache WHERE cache_key = $1',
[key]
);
if (pgResult.rows[0]) {
await redis.setex(key, 3600, JSON.stringify(pgResult.rows[0].transcript));
return pgResult.rows[0].transcript;
}
return null;
}
Redis TTL is 1 hour in this example. Postgres has no TTL; entries live until explicitly evicted. For most pipelines, Postgres alone is enough. Add Redis only after you have measured Postgres latency as the actual bottleneck.
The Full Pattern: Race-Safe Insert
async function getOrTranscribe(audioBuffer, opts) {
const key = cacheKey(audioBuffer, opts);
const cached = await getCached(key);
if (cached) return cached;
const result = await transcribeAPI(audioBuffer, opts);
await db.query(
`INSERT INTO transcript_cache (cache_key, audio_hash, engine, language, transcript)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (cache_key) DO NOTHING`,
[key, hashAudio(audioBuffer), opts.engine, opts.language, result]
);
return result;
}
ON CONFLICT DO NOTHING is load-bearing. Two simultaneous requests for the same audio file can both miss the cache and both call the API. The first insert wins; the second silently drops. Both callers get their result. No lock needed, no duplicate entries.
Storage-Cost Math
This is where caching's economics become concrete.
What a transcript costs to transcribe. Deepgram Nova-3 pre-recorded English runs $0.0043 per minute (as of July 2026, per deepgram.com/pricing). One hour of audio costs roughly $0.26. OpenAI's gpt-4o-transcribe runs $0.006 per minute, so about $0.36 per hour (per developers.openai.com pricing).
What a transcript costs to store. The plain transcript text for one hour of spoken English is approximately 48 KB (roughly 800 characters per minute in UTF-8). The full Deepgram JSONB response with word-level timestamps, utterances, topics, and sentiments runs 500 KB to 1 MB per hour, depending on speaker count and detail level. On AWS RDS PostgreSQL, storage runs about $0.115 per GB-month (per AWS pricing pages, July 2026).
Storing 1 MB of Deepgram JSON for one month costs roughly $0.000115. At $0.26 per avoided re-transcription, a single cache hit pays for storing that transcript for about 2,200 months.
The break-even question is not storage cost. It is engineering time. For a system already running Postgres, the pattern is around 30 lines of code. For a pipeline spending $200 per month on transcription with a 10% re-transcription rate, that is $20 per month recovered, or roughly $240 per year from one afternoon of work.
For more cost-saving tactics on the API side, see cost-optimizing transcription API calls and the broader transcription pricing comparison.
Invalidation on Model Upgrades
The cache key design handles model upgrades automatically. When you bump from Whisper Large-v3 to a newer model, the model name is part of the param hash, so existing entries no longer match. New requests generate new keys and trigger fresh transcriptions. Old entries become orphans that age out via your last-accessed eviction query.
Two other invalidation scenarios:
Customer-reported errors. A user flags a bad transcript and wants a redo. Expose an admin endpoint:
async function invalidateCacheForJob(jobId) {
const job = await db.jobs.findOne({ id: jobId });
await db.query(
'DELETE FROM transcript_cache WHERE cache_key = $1',
[job.cache_key]
);
}
Size-based eviction. Transcript tables grow indefinitely without eviction. If that becomes a concern, purge by last-accessed date:
DELETE FROM transcript_cache
WHERE last_accessed < NOW() - INTERVAL '90 days';
For most teams, transcripts are small relative to other data and the table stays manageable for years. Measure before evicting.
Segment-Level Caching for Long Files
For very long recordings (multi-hour podcasts, all-day conference audio), a flat cache either misses entirely or caches a very large blob. Per-segment caching is more granular:
Split the audio into 5-minute chunks. Hash each chunk independently and check the cache per chunk. Only transcribe the segments that are not cached. This also helps editing workflows: if a user re-uploads a file with a small change, only the modified segments need re-transcription.
Word-level caching is overkill. The storage overhead per lookup does not pay off. 5 to 10 minute segments are the right granularity.
Cache Hit Rates by Use Case
Realistic ranges from production pipelines:
| Use case | Typical hit rate |
|---|---|
| User re-submits same file | 8 to 15% |
| Internal tool re-runs | 60 to 80% |
| Backfill / batch reprocessing | 80 to 95% |
| Multi-tenant SaaS (independent users) | 0.5 to 3% |
The first row is the typical production case. A 10% hit rate on a $500/month transcription bill saves $50 per month. The backfill case is the highest-return scenario: if you ever need to reprocess a corpus, a cache turns a potentially large re-spend into nearly zero.
Tracking Cache Effectiveness
Add counters from the start so you know the cache is working:
async function getOrTranscribe(audioBuffer, opts) {
const key = cacheKey(audioBuffer, opts);
const cached = await getCached(key);
if (cached) {
metrics.increment('transcription.cache.hit');
return cached;
}
metrics.increment('transcription.cache.miss');
const result = await transcribeAPI(audioBuffer, opts);
await saveToCache(key, result);
return result;
}
Monitor the hit/miss ratio over time. If hit rate drops unexpectedly, something changed: a cache invalidation wave, a new file format that bypasses the hash, or an engine upgrade that generated new keys for existing content.
Integration With Webhooks
Caching pairs cleanly with async webhook pipelines, covered in webhook vs polling for transcripts. The webhook handler computes the cache key from the original audio hash and API params, then writes to the cache table on completion. Future requests for the same audio skip the API entirely.
For teams building internal tooling, caching is often the single highest-ROI optimization once volume clears roughly 50 hours per month, as covered in building an internal transcription tool.

If your use case is straightforward audio transcription without managing a caching layer, ConvertAudioToText handles dedup and result storage server-side, so you get results instantly on re-submitted files without any infrastructure to maintain.
Common Questions
Should I hash the filename or the file content?
Hash the content. Filenames lie: the same interview uploaded as interview.mp3 and interview_final.mp3 should hit the same cache entry. Two different files both named interview.mp3 should not collide. SHA-256 of the audio bytes is the only reliable identity signal.
What happens when I upgrade to a newer Whisper or Deepgram model?
Old cache entries stay valid but will no longer match new requests. Because the model name is part of the param hash in the cache key, any bump to the model version generates a different key and triggers a fresh transcription. Old entries expire naturally via your last-accessed eviction policy; you do not need to bulk-delete them.
Is Redis necessary or can Postgres alone handle caching?
Postgres alone is sufficient for most pipelines. Cache lookups on a primary-key index are fast. Add Redis as a hot-path layer only if you have measured high traffic on a small set of recently-accessed transcripts and Postgres latency is the actual bottleneck.
What cache hit rate should I expect?
It depends heavily on your use case. User re-submission of the same file (the most common case) typically yields 8 to 15% hit rates. Backfill or batch reprocessing jobs can hit 80 to 95%. Multi-tenant SaaS where users independently upload unrelated files is the hardest case: 0.5 to 3%, which may not justify the complexity until your transcription volume is high.
Sources
- Deepgram Nova-3 pricing: https://deepgram.com/pricing (checked July 2026)
- OpenAI transcription model pricing: https://developers.openai.com/api/docs/pricing (checked July 2026)
- AWS RDS PostgreSQL storage pricing: https://aws.amazon.com/rds/postgresql/pricing/ (referenced July 2026)
- Upstash Redis pricing: https://upstash.com/pricing/redis (checked July 2026)
Try transcription free
Convert any audio or video to clean, unwatermarked text — speaker labels, timestamps, and AI summaries included. First 30 minutes free, no account.
Related Articles

Best Transcription Tools with API Access (2026)
Which transcription SaaS tools actually give you API keys, and on which plan? Verified pricing and plan gates for Descript, Sonix, Fireflies, Happy Scribe, AssemblyAI, and more.

Internal Transcription Tool: Build vs Buy, Then Build
A practical architecture guide for building an internal transcription tool: when to build vs buy, SSO, access control, data retention, and real API integration code.