AI · · 8 min read
Translating text in the browser, with no API key and no server
Chrome ships a Translator and a LanguageDetector on the web platform. What the spec guarantees, the user gesture it demands, and why on-device is not promised.
Two JavaScript classes translate text and identify what language it is in, with no key, no endpoint and no per-character billing:
const translator = await Translator.create({
sourceLanguage: "en",
targetLanguage: "ja",
});
await translator.translate("Where does the week start?");They are defined by the Translator and Language Detector APIs specification, a Community Group draft from the W3C's Web Machine Learning group — not a W3C standard. At the time of writing they ship in desktop Chrome and Edge and nowhere else, including, notably, not in Chrome for Android.
That makes this a progressive enhancement, not a translation strategy. It is a useful one wherever your readers do not share a language, and it has enough sharp edges in the specification to be worth reading properly before wiring it into a UI.
github.com ↗Translation API specificationThe specification repository, including the language-arc matching examples. Source: W3C Web Machine Learning Community GroupThe two APIs
Translator translates between one fixed pair of languages. The pair is chosen at creation time and fixed for the life of the object.
const t = await Translator.create({ sourceLanguage: "zh-Hant", targetLanguage: "en" });
t.sourceLanguage; // "zh-Hant"
await t.translate(text); // a string
t.translateStreaming(text); // a ReadableStream
t.destroy(); // release the modelLanguageDetector tells you what a piece of text is written in, with confidence scores:
const detector = await LanguageDetector.create({
expectedInputLanguages: ["en", "ja", "ko", "zh-Hans"],
});
await detector.detect("週の始まりはいつですか");
// → [{ detectedLanguage, confidence }, { detectedLanguage, confidence }, …]detect() returns a sequence of results, not one. Take the first, but check the confidence before acting on it — a short comment is a much weaker signal than a paragraph.
Both interfaces are [Exposed=Window, SecureContext]. They are not available in workers, and not on a page served over plain HTTP.
Always ask about availability first
Both classes have a static availability() that returns one of four values.
| Value | Meaning | What to do |
|---|---|---|
available | Ready to use now | Create it |
downloadable | Usable after a download that has not started | Offer it behind a button |
downloading | A download is already in progress | Show progress, or wait |
unavailable | Not supported for these options | Fall back |
if (!("Translator" in self)) return fallback();
const status = await Translator.availability({
sourceLanguage: "en",
targetLanguage: "ko",
});
if (status === "unavailable") return fallback();One caveat the specification is unusually frank about: the answer may be masked on purpose. Availability is a fingerprinting signal, so the shared privacy text suggests user agents "mask by default" per API, options and storage key, until a page in that origin has successfully called create(). A first-time visitor may see downloadable for a model already on disk.
The user gesture nobody expects
This is the requirement that breaks the naive implementation. From the privacy requirements shared by these APIs: creating a model object "both requires and consumes user activation, when it would initiate a download".
In practice:
- If availability is
downloadable,create()needs transient activation — it must run in the turn of the event loop that handled a real click, tap or key press. Called on page load, it rejects withNotAllowedError. - Calling it consumes that activation, so a single click gets you one
create(), not two. - If availability is
downloadingoravailable, there is no download to start and no gesture needed. - The browser may also show its own confirmation before downloading, and
create()rejects withNotAllowedErrorif the user declines.
The transient-activation requirement is a default the algorithm applies unless a specific API opts out, and Translator and LanguageDetector do not. Summarizer, Writer and Rewriter do opt out, and need only sticky activation — the user has interacted with the page at some point. Do not carry an assumption from one of these APIs to another.
So the shape of a correct implementation is a button, not an effect:
button.addEventListener("click", async () => {
const translator = await Translator.create({
sourceLanguage: "en",
targetLanguage: "hi",
monitor(m) {
m.addEventListener("downloadprogress", e => {
progress.value = e.loaded; // a fraction from 0 to 1
});
},
});
output.textContent = await translator.translate(input.value);
});The monitor callback is the only way to show download progress, and when several pieces are downloaded — a base model plus language-specific material — they arrive as one bundled progress stream. create() does not resolve until all of them are done.
Aborting does not cancel
Every create() and every translate() takes an AbortSignal, and aborting rejects the promise immediately. It does not, however, stop the download.
That is deliberate: starting and cancelling downloads at will would let a page toggle the availability state and read the fingerprinting bits repeatedly. So the spec has a should-level requirement that user agents not cancel the underlying download when the signal aborts. Progress is kept, and a later availability() call reflects it.
In product terms: a reader who dismisses your translation prompt has still spent the bandwidth. Do not offer the download unless there is a good chance they want it.
Language tags are matched, not compared
You pass BCP 47 tags, and the API resolves them with the ECMAScript internationalisation matching rules rather than string equality. The specification illustrates this with a worked example of a hypothetical implementation that has English-to-Simplified-Chinese ready and English-to-Traditional-Chinese still to download:
await a("en", "zh") // "available" — zh best-fits zh-Hans
await a("en", "zh-CN") // "available" — best-fits zh-Hans
await a("en", "zh-HK") // "available" — best-fits zh-Hans
await a("en", "zh-TW") // "downloadable" — best-fits zh-Hant
await a("en-GB", "zh-Hant") // "downloadable" — en-GB best-fits enThose values are the spec's illustration of the matching rules, not a statement about any shipping browser. The rules themselves are the takeaway, and they have two consequences worth acting on.
First, the region subtag may not mean what you assume. zh-HK resolving to Simplified Chinese in that example is a modelling decision, not a typo, and exactly the kind of mismatch a reader in Hong Kong would notice immediately. Where the script matters, say so with zh-Hant or zh-Hans rather than leaving it to a region tag.
Second, availability() mutates the options you pass in to the best-fit match. Call it, then read back what you actually got, rather than assuming your requested tag survived.
One edge case is free: translating a language to itself can never return unavailable, so source and target arriving equal from user settings is a harmless no-op.
On-device is not a promise you can make
This is the easy assumption to make, and it is wrong. Nothing in the specification requires the translation to happen on the user's machine. The shared privacy considerations say plainly that "the implementation-defined parts of these APIs can be implemented by delegating to user-agent-provided cloud-based services", and go on: "this is something for web developers to be aware of when they use this API, in case their web page has requirements on not sending certain information to third parties."
The download machinery makes a local model the likely implementation, and a vendor may document what theirs does. But the platform guarantee is not there. If you handle medical notes, legal drafts or anything covered by a data-residency commitment, you cannot tell users that this API keeps their text on their machine — not on the strength of the standard alone.
The errors you have to handle
| Rejection | Cause |
|---|---|
NotAllowedError | No user activation when a download was needed, the user declined the download, or the permissions policy blocked the API |
NotSupportedError | Availability was unavailable for those options |
NetworkError | The download failed or could not be started |
QuotaExceededError | The input was larger than the model's input quota |
UnknownError | The model was evicted while the page was using it |
QuotaExceededError carries the details of what was exceeded, and you can check ahead with measureInputUsage() against inputQuota. If you were going to call translate() anyway, the shared spec text points out that catching the error beats measuring first.
NotAllowedError from permissions policy is the one that surprises people embedding a widget. Both APIs are gated on policy-controlled features — translator and language-detector — with a default allowlist of 'self'. In a cross-origin iframe they are unavailable until the embedding page opts in:
<iframe src="https://widget.example" allow="translator; language-detector"></iframe>Browser support
At the time of writing, from browser-compat-data:
| API | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
Translator | 138 (desktop only) | 148 | No | No |
LanguageDetector | 138 (desktop only) | 148 | No | No |
Summarizer | 138 (desktop only) | 138 | No | No |
LanguageModel | 148 (desktop only) | 138, behind a flag | No | No |
Two things in that table are easy to skim past. The compat data carries a note on Translator, LanguageDetector and Summarizer in Chrome: "availability may be subject to geographical restrictions" — so a supported browser version is not a guarantee, and where you are matters. And Chrome for Android records no support for any of them, which takes mobile off the table entirely. The honest summary is a desktop enhancement for a minority of your visitors.
Which is fine, as long as the design reflects it. Feature-detect, keep whatever you do for everyone else, and treat a working Translator as a bonus that removes a round trip rather than as the feature itself.
FAQ
Can I use this instead of a translation service?
No. It is unavailable in Firefox, Safari and Chrome for Android, and may be geographically restricted where it does ship. Use it to make an existing flow faster, not as the only path.
Why does create() reject on page load?
Because a download needs transient user activation. Call it from a click handler.
Is the text sent anywhere?
The specification permits implementations to use cloud services and does not require on-device processing. Check the specific browser's documentation before making any promise to users.
How large is the download?
The specification does not say, and it is implementation-defined. The security considerations acknowledge that models "could use significant amounts of the user's disk space" and let the browser refuse a download under storage pressure — which is a good reason to show progress and to handle NetworkError.
Does detect() work on one word?
It will return something, with a confidence score. Short strings are genuinely ambiguous across related languages, so read the confidence rather than trusting the top result.
Sources
- Translator and Language Detector APIs specification — Web Machine Learning Community Group draft, for the
TranslatorandLanguageDetectorinterfaces, availability and language matching - Writing Assistance APIs specification — the shared model-creation infrastructure, user-activation requirements, privacy and security considerations
translation-apirepository andwriting-assistance-apisrepository@mdn/browser-compat-data— per-browser version data and the geographical-restriction note
More to read
- Tech
Tech · · 7 min read
Animating between two separate pages, with two lines of CSS
A multi-page site can cross-fade between navigations with no JavaScript and no router. The five conditions, the descriptor both pages need, and what still needs script.

Design · · 7 min read
CSS anchor positioning: tooltips and menus that follow their trigger
Anchor positioning works in Chrome, Safari and Firefox. The properties you need, the flip-when-it-does-not-fit rule, and the repeated-name bug.