lingorelay / guides / stop paying twice
GuideStop paying for the same translation twice.
Most machine-translation bills aren't for translation. They're for re-translation — the same 200 dish names, bought again every time the menu renders. The fix is one line of thinking: make the translation addressable by its content.
The re-purchase problem
Per-character translation pricing is fair for new text. The trouble is that most production pipelines don't send new text — they send the same text again. A menu page that calls translate-on-render, a catalog that rebuilds nightly, a CMS that re-publishes on every edit: each run pays full price for strings that were translated identically last time. The output is byte-for-byte the same; only the invoice grows.
The cache key that fixes it
A translation is fully determined by three things: the source text, the target language, and whatever rules shaped it (in lingorelay, the project glossary). So address it by exactly those three:
cacheKey = sha256( text + "|" + targetLang + "|" + glossaryVersion ) # before calling the provider: # hit → return the stored translation. Cost: one DB read. Metered: 0 chars. # miss → translate once, store under cacheKey, return it.
Each part of the key earns its place:
text— hashing the full source string means only an exact repeat hits the cache. Change one character and it's honestly a new translation.targetLang— "fr" and "ja" are different products of the same input; they cache separately.glossaryVersion— the quiet hero. When you edit your glossary ("never translate Dinify", "render prawns as crevettes"), the version bumps, every affected key changes, and stale entries simply stop being found. Invalidation happens by construction — no cache-clearing cron job, no "why is it still showing the old wording" tickets.
Two implementation details worth copying: dedupe inside a batch too (the same string twice in one request should be translated — and paid for — once), and make cache reads free and unlimited, so a quota cap never takes already-translated content offline.
A worked example: one menu, three languages, rendered daily
Assumptions, stated plainly: a 200-item menu; each item's name + short description averages ~60 characters; you serve French, Japanese and Spanish; the menu page re-renders (and re-requests its translations) once a day for a month. Characters, not dollars — multiply by whatever your provider charges per character.
Characters actually sent to the model, over 30 days
| Translate-on-render (no cache) | Content-hash cache | |
|---|---|---|
| Day 1 (first pass) | 200 × 60 × 3 = 36,000 | 200 × 60 × 3 = 36,000 |
| Days 2–30 (nothing changed) | 29 × 36,000 = 1,044,000 | 0 — every request is a cache hit |
| Mid-month: you reword 10 dishes | included above | 10 × 60 × 3 = 1,800 |
| Month total | 1,080,000 chars | 37,800 chars (~29× less) |
In lingorelay plan terms: the cached column fits inside Starter's 200,000 monthly characters ($29 CAD/mo) with room for many more edits; the uncached column overruns Starter ~5× and even Growth's 1,000,000 ($79 CAD/mo) — for byte-identical output. The arithmetic is the argument.
Where this honestly doesn't help
- First-pass costs are the same as anyone's. A string the model has never seen must hit the model once, and we meter it exactly like any provider would charge for it. The cache buys you nothing on day one — it pays off on every day after.
- Low-repetition content caches badly. Live chat, user-generated reviews, one-off emails — mostly unique strings, mostly cache misses. If that's your whole workload, a cache layer is overhead, ours included.
- You can build this yourself. A hash column, a unique index, an upsert — a competent developer has v1 in an afternoon in front of any translation API. What you'd still have to build is the unglamorous rest: glossary versioning, per-language purging, batch dedupe, hit-count visibility, and quota rules that keep cached content serving. That finished version is the product we sell.
See your own hit rate, not our example's.
Create a project, send your real menu or catalog through POST /v1/translate twice, and read the stats line: the second call reports providerCalls: 0.