
Testing Chrome 157's LanguageDetector API on Android Without a Backend
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.
| Claim | Status |
|---|---|
| Chrome 157 stable enables on-device language detection on Android | Reported 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 bundled | Follows from downloadable appearing in the availability enum; confirm on your device |
| First call is slower than subsequent calls because of model load | Inferred from the download/load lifecycle, not measured here |
| Confidence values are comparable across inputs and devices | Almost 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, notavailable. - 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 amonitorcallback fordownloadprogress, andexpectedInputLanguagesto 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() | Meaning | What the UI should do |
|---|---|---|
"available" | Model present and usable | Use it |
"downloadable" | Model fetchable, not fetched | Offer to fetch; do not auto-fetch |
"downloading" | Fetch in progress | Show progress; fall back for now |
"unavailable" | Hardware, policy, or platform says no | Fall 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:
- Never block the input. The text field the user is typing into must stay interactive while the model fetches.
- Never re-prompt after refusal. If the user declines the download, remember that for the session and stop offering.
- Never assume the download completes. The tab can be backgrounded, the network can drop, the component can fail to extract. After
create()resolves, calldetect()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.zis 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:
| Input | Why it belongs in the suite | What a useful result looks like |
|---|---|---|
"Bonjour, comment allez-vous aujourd'hui ?" | Long, unambiguous, single script | High-confidence fr, clear margin over second place |
"ok" | Valid English, valid Indonesian-ish, plausible in dozens of contexts | Anything 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 decide | ja with confidence you should sanity-check against a zh string |
"我今天去银行" | Chinese; shares Han characters with the Japanese case | Should not collapse into ja |
"I need to send this to the 東京 office by 5pm" | Code-switched, English-dominant | en with a lower confidence than the monolingual English case |
"asdfghjkl" | Not a language | Unknown or low confidence — check what it does under gibberish |
"" and " " | Empty and whitespace | Should 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.9is 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:
- 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.
- Subsequent calls in the same session, same detector instance. Steady state. Same order of magnitude as any small classifier forward pass.
- 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
langattribute 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.
Tier 3: server-side detection with a consent gate
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.
LanguageDetectorappearing in stable Chrome on a non-Android platform — trivially checkable with the probe above inchrome://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
- Language Detector API — Chrome for Developers — the API reference and current availability notes for the built-in AI language detection surface.
- Built-in AI APIs overview — how the Language Detector, Translator, and Summarizer APIs relate, and the shared download lifecycle.
- Chrome Releases blog — primary source for the Chrome 157 stable entry and its platform notes.
- WebML Translation API explainer — the standards-side proposal covering translator and language detector behaviour.
- MDN:
navigator.language— reference for the Tier 2 fallback signal.
More posts

Auditing Android IVI Head Units for Proxy Botnet Abuse
A technical security analysis of how Android IVI head units can be abused as proxy nodes in a botnet, with a focus on infection paths, risk exposure, and defensive controls.

Vetting Heim Datacenter's European Expansion: Latency, Failover, and Exit Terms
A deep technical guide to vetting Heim Datacenter's Omnes Capital-backed European expansion, covering latency benchmarking, failover validation, and exit-term risk for engineering teams.

Why Appending to Your Agent's System Prompt Breaks Prompt Caching
Appending new instructions to an agent’s system prompt invalidates the cached prompt prefix, forcing full-price reprocessing and turning a $21 task into a $200 bill, so cache-stable prompt ordering is an architecture decision.
Loading comments…