← Read

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 Group

The 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 model

LanguageDetector 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.

ValueMeaningWhat to do
availableReady to use nowCreate it
downloadableUsable after a download that has not startedOffer it behind a button
downloadingA download is already in progressShow progress, or wait
unavailableNot supported for these optionsFall 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 with NotAllowedError.
  • Calling it consumes that activation, so a single click gets you one create(), not two.
  • If availability is downloading or available, there is no download to start and no gesture needed.
  • The browser may also show its own confirmation before downloading, and create() rejects with NotAllowedError if 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 en

Those 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

RejectionCause
NotAllowedErrorNo user activation when a download was needed, the user declined the download, or the permissions policy blocked the API
NotSupportedErrorAvailability was unavailable for those options
NetworkErrorThe download failed or could not be started
QuotaExceededErrorThe input was larger than the model's input quota
UnknownErrorThe 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:

APIChromeEdgeFirefoxSafari
Translator138 (desktop only)148NoNo
LanguageDetector138 (desktop only)148NoNo
Summarizer138 (desktop only)138NoNo
LanguageModel148 (desktop only)138, behind a flagNoNo

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

More to read