Testing Chrome 157's LanguageDetector API on Android Without a Backend

Testing Chrome 157's LanguageDetector API on Android Without a Backend

pr0h0••23 min read•tech
AI Usage (90%)
On this page

Introduction

Chrome 157 stable reportedly ships on-device language detection on Android. The seed I'm working from is a single sentence from BigGo Finance, published 4 November 2026 — secondary coverage of a browser release, thin enough that I won't pretend it is a spec. So this post does two things: it walks through what the shipped LanguageDetector API actually looks like to the code that calls it, and it keeps a hard line between what I can confirm from the material at hand and what needs the primary release notes. By the end you should have a typed capability probe, a minimal test harness you can run over adb on a real device, and a fallback ladder for everyone who is not on Chrome 157 on Android.

The position up front: the interesting part of this release is not "no backend." It is that availability() is now the API. Teams that write if ('LanguageDetector' in window) will ship a feature that looks present and behaves absent on most first visits — the model is a downloaded component, not a bundled one. The engineering work is the download lifecycle, the capability probe, and the fallback ladder. The detection call itself is three lines.

What the Chrome 157 release note actually says — and what it does not

What the report states, as a claim from a secondary source dated 4 November 2026:

  • Chrome 157 has reached stable.
  • On-device language detection is enabled on Android.
  • The framing is that this expands browser-local ML capability for web apps.

What that summary does not state, and what I will not invent:

  • The number of supported languages.
  • The model download size, or whether it ships as one component or several.
  • Minimum Android version or hardware class.
  • Whether desktop already had the capability, and if so since when.
  • Whether the model is delivered as a shared Chrome component or per-profile.

If you are making a ship/no-ship decision this week, read the 157 entry at chromereleases.googleblog.com and the Language Detector pages at developer.chrome.com/docs/ai first. Everything below is the integration analysis you do after reading those.

Confirmed versus inferred: separating the release fact from the API surface

Two kinds of statement are in play here, and conflating them is how bad posts about browser AI get written.

ClaimStatus
Chrome 157 stable enables on-device language detection on AndroidReported by secondary coverage (BigGo Finance, 2026-11-04); verify against primary release notes
The API surface is LanguageDetector.availability(), create(), detect()Stated in Chrome's built-in AI documentation; testable in your own console
The model is downloaded on demand rather than bundledFollows from downloadable appearing in the availability enum; confirm on your device
First call is slower than subsequent calls because of model loadInferred from the download/load lifecycle, not measured here
Confidence values are comparable across inputs and devicesAlmost certainly false; treat them as ordinal within a single call, at best

The rest of this post keeps that table in view.

The Short Version for People Shipping This Week

What changes for backend-free apps

If you currently POST text to an endpoint that returns a language tag, you can move that classification into the page for Android Chrome users on 157+. Concretely, three things become possible without a server:

  • Pre-filling a locale picker from a free-text field (a comment box, a support form, a search query) before the user has declared anything.
  • Choosing which client-side model or prompt variant to load for a chat feature.
  • Making a routing decision in a client-only app — a static PWA, an offline-first tool — where no backend exists at all.

None of those are security decisions. They are UX decisions, which is exactly the class of decision this API is suited to.

What does not change even without a backend

  • You still need a fallback. First-run users get downloadable, not available.
  • You still need a server path for iOS, for desktop Firefox/Safari, and for Android Chrome below 157.
  • You still cannot use detected language as proof of anything. It is a classifier output with a float attached, over user-supplied text.
  • You still need a locale the user declared for anything legally or contractually meaningful.

How On-Device Language Detection Fits Chrome's Built-in AI Model

The LanguageDetector API surface: create, availability, detect

The shape, as documented for Chrome's built-in AI APIs, is small:

  • LanguageDetector.availability() — async, resolves to one of "unavailable", "downloadable", "downloading", "available".
  • LanguageDetector.create(options) — async, resolves to a detector instance. Options include a monitor callback for downloadprogress, and expectedInputLanguages to bias the model toward the languages you actually care about.
  • detector.detect(text) — async, resolves to an array of { detectedLanguage, confidence }, highest confidence first.
  • detector.destroy() — releases the instance.

The return shape is an array, not a scalar. That matters: the top result is a ranking, not a verdict. A useful pattern is to check the top result and the margin between the top two before acting.

// Shape of what detect() resolves to. Values below are illustrative of the
// structure only — they are not measurements.
const results = [
  { detectedLanguage: "fr", confidence: 0.9 },  // illustrative
  { detectedLanguage: "en", confidence: 0.1 },  // illustrative
];

If the top score is high but the second is close behind, the honest conclusion is "ambiguous," not "French." Write your consumer code for three outcomes: confident, ambiguous, unknown.

Where the model lives on Android (component download, not bundled in the APK)

The "downloadable" state is the tell. If the model shipped inside the Chrome APK, availability would collapse to two states: available or unavailable-by-blocks. The four-state enum means there is a discrete download step, and Chrome names it for you.

Practical consequences:

  • The first user pays a download. Your feature must work before that download finishes.
  • Downloads can be evicted. Components managed by the browser are subject to storage pressure. I have not verified eviction behaviour for this specific component on Android — mark that as untested — but the design implies you must treat availability as volatile rather than caching a boolean in localStorage.
  • Downloads should be user-initiated. Triggering a multi-megabyte model fetch because a page loaded is hostile. Gate it behind an explicit opt-in or a visible gesture.

Relationship to Translator and Summarizer APIs

These are sibling APIs in the same built-in AI family, with the same lifecycle: availability() → create() → use → destroy().

The obvious composition is detection feeding translation: detect the source language of an inbound message, then hand it to Translator.create({ sourceLanguage, targetLanguage }). That saves the translator its own detection pass and lets you choose a target locale that makes sense (the user's navigator.language, not the detected one).

The Summarizer sits at the other end of the pipeline: detect → translate → summarize. Each hop has its own availability() state, which is why the capability probe below returns a structure rather than a boolean — you are probing three APIs, not one, and they will disagree.

Feature Detection That Does Not Lie to You

Why if ('LanguageDetector' in window) is not enough

'LanguageDetector' in window tells you the interface exists. On Android Chrome 157 it will be true for essentially every user, including every user who has never downloaded the model and every user whose device Chrome has decided cannot run it. You will then call create(), sit there for several seconds of download, and blow your perceived-performance budget on a feature the user never asked for.

The membership test answers "does this browser expose the interface." You need the answer to "can I classify this string right now, without a download, on this device."

Checking availability() and handling downloadable

Four states, four different product behaviours:

availability()MeaningWhat the UI should do
"available"Model present and usableUse it
"downloadable"Model fetchable, not fetchedOffer to fetch; do not auto-fetch
"downloading"Fetch in progressShow progress; fall back for now
"unavailable"Hardware, policy, or platform says noFall back permanently this session

Treat "unavailable" as sticky for the session and everything else as volatile.

A capability probe that returns a typed result instead of a boolean

Booleans lose information. Return a discriminated result so callers can branch without string-sniffing:

/**
 * @returns {Promise<
 *   | { ok: true, detector: LanguageDetector }
 *   | { ok: false, reason: "api-missing" | "availability-threw" | "unavailable" | "needs-download",
 *       availability?: string, creatable?: boolean }
 * >}
 */
async function probeLanguageDetector() {
  if (typeof LanguageDetector === "undefined") {
    return { ok: false, reason: "api-missing" };
  }

  let availability;
  try {
    availability = await LanguageDetector.availability();
  } catch (err) {
    return { ok: false, reason: "availability-threw", availability: String(err) };
  }

  if (availability === "unavailable") {
    return { ok: false, reason: "unavailable", availability };
  }

  if (availability === "downloadable" || availability === "downloading") {
    return { ok: false, reason: "needs-download", availability, creatable: true };
  }

  const detector = await LanguageDetector.create({
    expectedInputLanguages: ["en", "fr", "es"],
  });
  return { ok: true, detector };
}

The try/catch around availability() is not defensive noise. An API that touches device capability and disk state can reject, and a rejected probe inside a render path is an unhandled promise rejection that takes down your feature flag logic with it.

Wiring a progress-safe model download into a non-blocking UI

The monitor callback receives a target you attach downloadprogress to. e.loaded is a fraction from 0 to 1.

async function upgradeToLocalDetector(onProgress) {
  const detector = await LanguageDetector.create({
    expectedInputLanguages: ["en", "fr", "es"],
    monitor(m) {
      m.addEventListener("downloadprogress", (e) => {
        // e.loaded is 0..1
        onProgress(Math.round(e.loaded * 100));
      });
    },
  });
  return detector;
}

Three rules for the surrounding UI:

  1. Never block the input. The text field the user is typing into must stay interactive while the model fetches.
  2. Never re-prompt after refusal. If the user declines the download, remember that for the session and stop offering.
  3. Never assume the download completes. The tab can be backgrounded, the network can drop, the component can fail to extract. After create() resolves, call detect() once with a known string before enabling the feature.

Hands-On: A Minimal Android Test Harness

Test page setup and a chrome:// inspection checklist

The harness is a single HTML file with no build step. Serve it over localhost on the device so it counts as a secure context:

## make the harness reachable from the phone
adb reverse tcp:8080 tcp:8080
python3 -m http.server 8080

## open it in Chrome on the device
adb shell am start -a android.intent.action.VIEW \
  -d "http://localhost:8080/ld-harness.html" com.android.chrome

Attach DevTools over the abstract socket rather than hunting for a USB toggle:

adb forward tcp:9222 localabstract:chrome_devtools_remote
## then open chrome://inspect in desktop Chrome

Before touching the API, record the environment. Two chrome:// pages do most of the work:

  • chrome://version — exact Chrome build. Write the full version string into your notes; 157.x.y.z is a version range, not a version.
  • chrome://on-device-internals (if present in your build) — component/model state. If the page 404s on your build, say so in your notes instead of assuming the model is missing.

Also confirm the binary and the device:

adb shell dumpsys package com.android.chrome | grep -E "versionName|versionCode"
adb shell getprop ro.build.version.release
adb shell getprop ro.product.model

Detecting short strings, mixed scripts, and code-switched input

The suite that actually finds the detector's edges is small and nasty:

InputWhy it belongs in the suiteWhat a useful result looks like
"Bonjour, comment allez-vous aujourd'hui ?"Long, unambiguous, single scriptHigh-confidence fr, clear margin over second place
"ok"Valid English, valid Indonesian-ish, plausible in dozens of contextsAnything is defensible; must not be treated as confident
"no"English ("no"), Spanish ("no"), Italian ("no"), Norwegian ("no")Same — this is the anti-test for your margin check
"東京は今日雨です"Japanese with kanji; script alone narrows but does not decideja with confidence you should sanity-check against a zh string
"我今天去银行"Chinese; shares Han characters with the Japanese caseShould not collapse into ja
"I need to send this to the 東京 office by 5pm"Code-switched, English-dominanten with a lower confidence than the monolingual English case
"asdfghjkl"Not a languageUnknown or low confidence — check what it does under gibberish
"" and " "Empty and whitespaceShould reject or return empty; check it does not throw

The empty-string row is not pedantry. A support form will submit whitespace, and if detect("") rejects, your unguarded await becomes a production error path.

Recording observed confidence values and what they do not mean

Record the raw array, not just the top language, plus the margin:

async function record(detector, label, text) {
  const t0 = performance.now();
  const results = await detector.detect(text);
  const ms = performance.now() - t0;
  const top = results[0];
  const second = results[1];
  return {
    label,
    text,
    ms: Number(ms.toFixed(2)),
    top: top?.detectedLanguage ?? null,
    confidence: top ? Number(top.confidence.toFixed(4)) : null,
    margin: top && second
      ? Number((top.confidence - second.confidence).toFixed(4))
      : null,
  };
}

What those numbers do not mean:

  • They are not calibrated probabilities in any documented sense. A 0.9 is not "90% likely to be French" in a way you can put in a contract.
  • They are not comparable across devices or Chrome versions. Different model builds produce different scores for the same input.
  • They are not comparable across input lengths. A long paragraph and a two-word string are not on the same scale.
  • They are not stable over time. A component update changes the model.

Use them for one decision, made in one place, on one input: is the top result far enough ahead of the second to act on it? Pick a threshold empirically from your own suite, write down the device you picked it on, and expect to re-tune it.

Reading the results table: where the detector is reliable and where it is not

From the mechanism rather than from a benchmark I can publish here — flag this as inference:

  • Reliable: long-form, monolingual text in a widely-spoken language with a distinctive character set. A 40-word French or Korean paragraph is the easy case.
  • Unreliable: strings under roughly a dozen characters; single words that are valid in several languages; input dominated by proper nouns, product names, or URLs.
  • Actively dangerous as a signal: code-switched text, transliterated text (Romanized Hindi, Japanese in romaji), and anything where the "wrong" answer causes a visible failure for the user.

The failure mode is asymmetric. Getting a long paragraph wrong is embarrassing. Getting "no" wrong in a language-gated workflow is a support ticket.

Latency: What On-Device Actually Buys You

Cold start versus warm inference on mid-range Android hardware

I have not benchmarked Chrome 157 on Android hardware for this post, and I am not going to print millisecond figures I did not measure. What I can give you is the shape of the curve plus the harness that produces your own numbers.

Three regimes, and they are not close to each other:

  1. First call after page load, model present on disk. You pay model load. This is the dominant cost and the one people forget to measure, because they benchmark in a warm console.
  2. Subsequent calls in the same session, same detector instance. Steady state. Same order of magnitude as any small classifier forward pass.
  3. First call when the model is not present. You pay a network download. This is not latency, it is a product decision.

The first-call cost is model load, not detection

The practical implication: measure the cost of create(), not just detect(). If you lazily create the detector on the keystroke that needs it, you have moved model load into the input handler, which is the worst possible place for it.

The fix is boring and effective: create the detector during idle time after the page becomes interactive, keep it for the session, and destroy() on teardown or when the feature is closed.

let detectorPromise = null;

function warmDetector() {
  detectorPromise ??= probeLanguageDetector().then((r) => (r.ok ? r.detector : null));
  return detectorPromise;
}

// later, off the critical path
if ("requestIdleCallback" in window) {
  requestIdleCallback(warmDetector, { timeout: 4000 });
} else {
  setTimeout(warmDetector, 2000);
}

Measuring honestly with performance.mark and measure

Wrap creation and detection separately, and never lean on console.time inside an async callback where you will forget the timeEnd:

async function measure(label, fn) {
  performance.mark(`${label}:start`);
  try {
    return await fn();
  } finally {
    performance.mark(`${label}:end`);
    performance.measure(label, `${label}:start`, `${label}:end`);
  }
}

const created = await measure("ld:create", warmDetector);
const detected = await measure("ld:detect", () => detector.detect(text));

const entry = performance.getEntriesByName("ld:detect").at(-1);
console.log("detect ms", entry.duration.toFixed(2));

Two measurement traps:

  • Do not measure the first detect() and call it latency. It includes whatever lazy initialisation the implementation defers.
  • Do not measure on a cold tab and a warm tab and average them. Report them as separate regimes with separate labels, or your numbers are meaningless.

When the network round trip is still faster (and why that is not the win)

If your backend is in the same region as the user and the payload is 40 bytes, the round trip is probably faster than a cold local model load. On a warm session, local should win, but it is not guaranteed and it depends on the device.

So if raw speed is not the win, what is?

  • Tail latency. The p99 for a network call on a congested mobile connection is brutal. Local inference has no tail driven by radio state.
  • Offline. A PWA with a downloaded model classifies text with the radio off.
  • No per-request cost. Detection volume stops showing up on your bill.
  • No text in the request body. This is the real one, and it is the next section.

If "it's faster" is your only justification for moving to the local detector, measure first on a mid-range device. You may be disappointed.

Privacy Tradeoffs in Plain Terms

What on-device really removes from the request path

It removes the user's text from a third-party HTTP request. Not from the page, not from localStorage, not from a crash report — from the request path that would otherwise hand a verbatim user string to your server and, typically, to your logs and your observability vendor.

That is a genuine reduction in exposure, and it is the strongest argument for this API. A comment drafted but never submitted no longer transits a network to be classified.

What still leaks: page context, timing, and telemetry

Local inference does not make you private. It changes the shape of what you emit:

  • Timing. The moment you enable a language feature is observable. A user who types in a field and then sees a translated UI has revealed the language of their input to anyone watching the DOM, including injected scripts.
  • Your own telemetry. The obvious next move is to log the detected language for analytics. That recreates the exposure you just removed, in a more durable place. If you log it, log the bucket ("not-the-default-locale"), not the tag.
  • Page context. If you translate, you are probably also sending the text to a translation API. Detecting locally and then translating remotely means the text still leaves. Only the classification step got cheaper.

Threat models where local inference genuinely matters

  • Regulated content that must not transit a third party per-request. Local classification keeps the classification step inside the user agent.
  • Offline and air-gapped-ish deployments where the request path is unavailable or undesirable.
  • High-volume free-text triage where sending every draft to a server creates a data-retention obligation you would rather not have.

Threat models where it changes nothing

  • A malicious page script. If an attacker has script execution in your origin, they can call detect() on whatever they like. Local inference is not a confidentiality boundary against same-origin code.
  • A compromised backend. If you still send the text for the actual feature, local detection changed one line of your data flow.
  • Device-level adversaries who can read the browser's storage and memory.

Fallback Strategies That Degrade Gracefully

Tier 1: local LanguageDetector available

Warm the detector at idle, act only when the margin between the top two results clears a threshold you tuned yourself, and treat everything else as ambiguous. Keep the detector instance for the session.

Tier 2: no API, but a cheap heuristic

Cheap and wrong in interesting ways, but free and instant:

  • navigator.language — the user's preferred UI locale. Not the language of their text, and frequently not even close (a Turkish user on an English-locale device).
  • The element's lang attribute or the document's — inherited context. Often the best available signal, and it costs nothing.
  • Script-range sniffing — check whether the string is predominantly Latin, Han, Hiragana/Katakana, Hangul, Cyrillic, Arabic, Devanagari. Script narrows the field without identifying a language. Han does not distinguish Chinese from Japanese; Kana presence is strong evidence of Japanese.
const SCRIPT_TESTS = [
  ["ja", /[\u3040-\u30ff]/],        // kana present -> Japanese-leaning
  ["ko", /[\uac00-\ud7af]/],        // hangul
  ["zh", /[\u4e00-\u9fff]/],        // han (also matches ja text; check kana first)
  ["ru", /[\u0400-\u04ff]/],        // cyrillic
  ["ar", /[\u0600-\u06ff]/],        // arabic
];

function guessByScript(text) {
  for (const [lang, re] of SCRIPT_TESTS) {
    if (re.test(text)) return { language: lang, source: "script", confident: false };
  }
  return { language: null, source: "none", confident: false };
}

Order matters: test kana before Han, or every Japanese sentence becomes Chinese. And note the confident: false — this heuristic should never take an irreversible action.

Last resort, and it should be visible. If you must send text to a server to classify it, say so at the point of collection, scope the payload to the minimum string needed, and do not persist the text alongside the label unless you have a stated reason.

Deciding the fallback policy per feature, not per app

One global "language detection" abstraction will fail. A locale-picker pre-fill can live happily on Tier 2 heuristics; it is a suggestion the user can override. An automatic UI-language switch cannot — that one waits for Tier 1 or uses the declared locale. An irreversible action (deleting, sending, billing) should never touch Tiers 2 or 3 at all.

Write the policy next to the feature:

feature: comment-draft locale hint
tier1: use (threshold 0.25 margin)
tier2: use, label as "guess"
tier3: never
irreversible: no

Browser-Support and Platform Caveats

Android-only in this release; desktop and iOS status is unconfirmed

The report scopes this to Android. I am not going to claim anything about desktop Chrome from a summary that does not mention it, and iOS is a separate engine entirely — the built-in AI APIs are a Chrome feature, and on iOS every browser ships WebKit. Assume nothing; probe at runtime.

Secure context, permissions policy, and iframe behavior

These APIs are exposed to secure contexts — https: or localhost. Your harness served over adb reverse on http://localhost:8080 qualifies, which is why the setup above works. A page served over plain http:// on a LAN IP will not, and the failure is a missing global, not an error message.

For iframes: built-in AI APIs may be gated behind a permissions policy directive. I have not verified the directive name for this release and will not guess it — check the API docs before you embed the harness in an iframe, and if you cannot find it, test it rather than assuming.

Storage pressure, model eviction, and low-end device behavior

Two untested-but-likely behaviours worth designing for:

  • Eviction. Components can be dropped under storage pressure, so treat availability as a per-session fact rather than a cached true.
  • Refusal on low-end hardware. Some devices will report "unavailable" regardless of storage. Your fallback must be the default path, not an error path.

Version-pinning your tests so a 157.x patch cannot silently break you

Record the full version in every result row and compare it later:

async function buildInfo() {
  if (!navigator.userAgentData?.getHighEntropyValues) {
    return { ua: navigator.userAgent };
  }
  const d = await navigator.userAgentData.getHighEntropyValues(["fullVersionList"]);
  return { brands: d.fullVersionList, platform: navigator.userAgentData.platform };
}

A component update inside a patch release can change both availability and confidence distributions. If your threshold was tuned on 157.0.1000.0 and the user is on 157.0.1000.42, you are running a different classifier than the one you tested. Log the full version with every result and re-run the suite when it moves.

Where Not to Use the LanguageDetector API

As an access-control or compliance signal

Never. detect() returns a float over user-controlled input. If your logic is "if the language is X, allow", you have built an access control check on a value the user types into a text box. That is a bug class, not a feature.

As a substitute for user-declared locale

navigator.language reflects a preference the user set. A detected language reflects a string they typed once. Users write in multiple languages, quote other people, paste URLs, and type in a second language to a colleague. Pre-fill from detection; never override a declared preference with it.

As a routing key for irreversible actions

Auto-sending a message in a detected language, auto-deleting records in a detected jurisdiction, auto-applying a template — all of these need a human confirmation step. If the cost of a wrong answer is more than "the user clicks to correct it," the detector is the wrong tool.

What to Watch Next

Signals that would confirm broader platform support

  • The primary 157 release notes listing platform availability explicitly, including desktop.
  • The built-in AI docs adding a support matrix rather than prose.
  • LanguageDetector appearing in stable Chrome on a non-Android platform — trivially checkable with the probe above in chrome://inspect.
  • The WebML explainer repository updating its implementation-status section.

What would change the recommendation if the model is evicted or gated

If model eviction turns out to be common on mid-range Android — which I have not tested — the practical advice shifts from "warm at page load" to "treat this as an opt-in, once-per-session capability, and never put it on the critical path." That is a meaningful downgrade from "just use it for locale hints."

If the download turns out to be gated behind a browser-level setting most users never enable, effective availability is near zero and the fallback ladder becomes the product. Watch the availability() distribution in your own telemetry — bucketed, and without sending text — and let that decide where the effort goes.

Conclusion

Chrome 157 shipping on-device language detection on Android is a real capability, and the API is small enough to read in five minutes. The engineering is everywhere else: the four-state availability check, the user-initiated download with a progress UI, the margin threshold you tuned on your own device, and the fallback ladder that keeps the feature useful for every user who is not on 157 on a device that can run it.

The position I would take to a team planning to ship this: build it as a Tier 1 enhancement to a feature that already works, never as the feature itself. Probe availability() on every session. Return a typed result, not a boolean. Measure create() and detect() separately, on hardware, and write the version string next to the numbers. And keep detection out of anything that cannot be undone by a click.

Further Reading

Share this post

More posts

Loading comments…