●SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17●MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_content●CHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoff●CLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10●DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 models●CHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite●SUNSET — Six days until the image generation models shut down: the imagen-4.0 family and Gemini 3 Image models stop on August 17●MIGRATE — gemini-3.1-flash-image is the recommended replacement, and it means rewriting generate_images calls as generate_content●CHECK — The same prompt will not necessarily produce the same picture after migrating, so secure any images you still need before the cutoff●CLASSROOM — August 17 is also the day Gemini in Classroom arrives on mobile; the web rollout to students of all ages began on August 10●DEPRECATION — The Grok 4.1 family shuts down on August 20, and gemini-robotics-er-1.6-preview on August 31, succeeded by the er-2 models●CHANGELOG — The Gemini API changelog still ends at July 30. The most recent major change remains the GA of Gemini 3.6 Flash and 3.5 Flash-Lite
When Gemini Nano Won't Run — Chrome and Android Have Different Doors
Pasting a Gemini Nano sample often fails. Chrome moved from the ai.* namespace to LanguageModel, and Android's entry point is ML Kit GenAI. Here is what works today, plus the device requirements nobody mentions.
If summarization ran offline, it would work in places with no signal. That was the thought behind pasting a sample into the Chrome console. What came back was a single line:
Uncaught ReferenceError: ai is not defined
I checked the spelling. I checked the flags. I restarted Chrome. It was none of those. The ai namespace itself no longer existed.
Gemini Nano is usually described as "the lightweight Gemini," but the thing that actually blocks you isn't model capability. It's which door you walked through, and whether you assumed the model was already on the device. Miss either one and your prompt never executes, no matter how well written it is.
Here's where Chrome and Android each stand today.
ai.languageModel no longer exists
Early builds of Chrome's Built-in AI hung everything off a global called ai (or window.ai). Most samples still floating around date from that period.
Today each capability lives on its own global class.
// Old: throws "ai is not defined"const session = await ai.languageModel.create({ systemPrompt: "You are a helpful assistant."});// Current: call the LanguageModel global directlyconst session = await LanguageModel.create({ initialPrompts: [ { role: 'system', content: 'You are a helpful assistant. Keep answers brief.' } ]});
The system prompt moved too. Instead of a dedicated systemPrompt field, you put a role: 'system' message at the head of initialPrompts. Restoring a stored conversation uses the same array with user and assistant entries, which I find more honest about what's actually happening.
Notice type went from "tl;dr" to 'tldr'. Fix only the namespace and you'll get rejected on the option value instead — one error swapped for another.
These APIs also haven't landed as one bundle. As of July 2026 they're spread across different stages.
API
From a web page
From a Chrome extension
Translator / Language Detector
Stable in Chrome 138
Stable in Chrome 138
Summarizer
Stable in Chrome 138
Stable in Chrome 138
Prompt
Stable in Chrome 148
Stable in Chrome 138
Writer / Rewriter
Developer trial
Developer trial
Proofreader
Developer trial
Developer trial
Look at the Prompt row. Extensions got it as stable in 138; ordinary web pages had to wait until 148. Ten versions apart, for the same API. That gap is exactly why code that worked in your extension can die when you move it to a page.
I'd hold off on building a core feature on Writer or Rewriter. A developer trial is something you adopt knowing the shape may change. The Built-in AI API status table is the source of truth here.
Skip availability() and create() will fail
Fix the namespace and it still might not run. This is the next place to look.
Gemini Nano does not ship inside Chrome. The API is built into the browser, but the model downloads separately the first time an origin uses it. So at the moment your code runs, the model may simply not be there.
That's why the current API is shaped around asking before acting.
// 1. Check the state// Pass availability() the same options you'll pass prompt()const availability = await LanguageModel.availability({ expectedInputs: [{ type: 'text', languages: ['en'] }], expectedOutputs: [{ type: 'text', languages: ['en'] }],});if (availability === 'unavailable') { // Device doesn't meet the requirements — route to cloud, or hide the feature return fallbackToCloud();}// 2. Start the download from a user gesture// When availability is 'downloadable', create() needs user activationdownloadButton.addEventListener('click', async () => { const session = await LanguageModel.create({ monitor(m) { m.addEventListener('downloadprogress', (e) => { progressBar.value = e.loaded * 100; }); }, }); // 3. Only now can you run const result = await session.prompt('Summarize this article in three lines.'); console.log(result);});
Two things get skipped here more than anything else.
First, pass identical options to availability() and prompt(). Some language and modality combinations aren't supported, and if the options drift apart you get "available" back and then a NotSupportedError at execution time.
Second, user activation. Calling create() on page load will always fail on a device without the model. That's deliberate — a multi-gigabyte download shouldn't start without consent. Your dev machine already has the model, so this passes locally and surfaces for the first time in production.
Working solo as an indie developer, I nearly signed off on code that skipped both. It "worked" only because my own Chrome already had the model cached. Since then I treat the first-run experience on a model-less device as the real test, not the happy path on my laptop.
One more detail: availability() returns one of 'unavailable', 'downloadable', 'downloading', or 'available'. It's not a boolean, so if (availability) happily lets 'unavailable' through.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The check to download to run pattern that actually works in current Chrome and Android
✦First-download and inference latency measured on one machine, next to a cloud round-trip
✦Exception branches that survive QuotaExceededError and model eviction, always falling back to cloud
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Collapsing "does the name exist" and "can I use it" into one function
Writing availability() at every call site scatters your UI branching fast. The decisions you actually need come down to three: show the feature, prompt for a download, or route to the cloud. So I fold the entry point into a single function and call that instead.
Feature detection comes first. On older Chrome builds and on Android, the global simply isn't there. Reach for availability() without checking the name and you get a ReferenceError before any decision is made — taking the surrounding UI with it.
const OPTS = { expectedInputs: [{ type: 'text', languages: ['en'] }], expectedOutputs: [{ type: 'text', languages: ['en'] }],};// Returns exactly one of 'ready' | 'prompt-download' | 'cloud'export async function resolveOnDeviceMode() { // Old Chrome, Android, iOS — settled right here if (!('LanguageModel' in self)) return 'cloud'; let state; try { state = await LanguageModel.availability(OPTS); } catch { // Unsupported language/modality combinations land here return 'cloud'; } switch (state) { case 'available': return 'ready'; case 'downloading': return 'prompt-download'; // just show progress and wait case 'downloadable': return 'prompt-download'; // show a button, wait for the gesture default: return 'cloud'; // 'unavailable' }}
Putting 'LanguageModel' in self first removes the exception I kept hitting on Android Chrome. It has to sit ahead of the try/catch — in an environment without the name, you can't even write the availability() call.
Fixing the return value at three states keeps the view layer honest. ready runs it, prompt-download shows a button, cloud quietly falls through to the Gemini API. Three branches means three test cases.
If you plan to reuse sessions, add clone() and cancellation early.
const base = await LanguageModel.create({ initialPrompts: [{ role: 'system', content: 'Return only a short classification label.' }],});// Branch without polluting the shared historyconst branch = await base.clone();let ac;input.addEventListener('input', async () => { ac?.abort(); // cancel the inference already in flight ac = new AbortController(); const label = await base.prompt(input.value, { signal: ac.signal }); render(label);});
On a screen that infers per keystroke, that cancellation matters. Fire the next prompt without aborting the last and a stale result lands afterwards, swapping the label back. Keeping one base session and branching with clone() also proved faster than rebuilding a session that carries initial prompts.
What happens when someone closes the tab mid-download
There's a stretch right after the user taps your download button that most implementations leave thin: the several minutes the model takes to arrive, during which people close the tab and come back later.
Start with the shape of the value downloadprogress hands you. e.loaded is a fraction between 0 and 1, and no byte total comes with it. You can't build an "X MB remaining" label, so percentage plus elapsed time is what you have to work with.
When they return, availability() may report 'downloading'. The download lives in the browser, not in the tab that started it. Write a naive create() at that point and you're awaiting a promise that won't settle for minutes, with nothing on screen.
availability() returns
What to show
Call create()?
downloadable
A start button, plus one line saying it takes minutes
Only inside the click handler
downloading
Progress bar, resumed from last known point
Yes — attach monitor just to read progress
available
The feature itself
Yes — init is under a second
unavailable
Cloud path, no button at all
No
One wrapper around create() absorbs both the re-entry and the double-start problem.
let inflight = null; // shared promise so two never run at onceconst PROGRESS_KEY = 'nano:download-progress';export function ensureSession(onProgress) { if (inflight) return inflight; // every later caller gets the same promise inflight = (async () => { // On a return visit, start drawing from where we left off — never rewind to 0% const saved = Number(localStorage.getItem(PROGRESS_KEY) || 0); if (saved > 0) onProgress(saved); try { return await LanguageModel.create({ ...OPTS, monitor(m) { m.addEventListener('downloadprogress', (e) => { const pct = Math.round(e.loaded * 100); // e.loaded is a 0–1 fraction localStorage.setItem(PROGRESS_KEY, String(pct)); onProgress(pct); }); }, }); } finally { inflight = null; localStorage.removeItem(PROGRESS_KEY); } })(); return inflight;}
The inflight guard matters because real screens call ensureSession() from more than one place. If a summarize button and an autocomplete field both want the same session, an unwrapped version fires create() twice.
Always clear the saved percentage on completion. Leave it behind and the next re-download — the one that starts after the model gets evicted — opens at 90%.
One more detail: on my machine, several seconds passed between the bar reaching 100% and create() actually resolving. That's unpacking. A bar frozen at 100% reads as a hang, so I switch the label to "Getting things ready" at that point.
Calling create() from the 'downloading' state went through without user activation here. That's the kind of behavior that shifts between releases, though. Keep the button around and the screen holds up either way.
If the same origin is open in several tabs, each tab receives its own progress events. Broadcasting the percentage over a BroadcastChannel keeps one tab from looking stalled while another advances.
22 GB free, and what happens below 10 GB
"On-device" doesn't mean "no constraints." Chrome's Built-in AI has hard requirements.
Item
Requirement
OS
Windows 10 / 11, macOS 13+, Linux, ChromeOS (Chromebook Plus)
Storage
At least 22 GB free on the volume holding the Chrome profile
GPU
More than 4 GB VRAM
CPU (without GPU)
16 GB RAM or more, 4 cores or more
Network
Only for the initial download (metered connections excluded)
The 22 GB figure isn't the model's size. It's headroom for downloading and unpacking.
The operationally nasty part comes next. If free space drops below 10 GB after the download, the model is removed from the device. It re-downloads once requirements are met again, but in the meantime your on-device feature quietly disappears.
Which means "it worked once, so it works" doesn't hold. Your fallback path isn't just for first run — it's for a state the user can wander back into at any time, and you can't see their disk.
You can check the current model size at chrome://on-device-internals. When behavior looks strange, start there.
Two more constraints worth knowing before you design:
Web Workers aren't supported. Permission policy checks make this complicated, so for now it's top-level windows and same-origin iframes only. You can't push inference off the main thread this way yet.
Cross-origin iframes need explicit delegation — <iframe src="..." allow="language-model">.
Android doesn't go through Chrome's API — the door is ML Kit GenAI
This is the piece I see misunderstood most.
Chrome's Built-in AI APIs do not work in Chrome for Android, or on iOS. Desktop and Chromebook Plus only. "Gemini Nano on your phone" and "Gemini Nano in your browser" share a model name and almost nothing else from a developer's point of view.
The supported door for Android apps is ML Kit's GenAI APIs, built on top of AICore, an Android system service. One Gemini Nano lives on the device and every app shares it — nothing to bundle.
You get task-specific APIs (summarization, proofreading, rewriting, image description) plus a GenAI Prompt API for free-form requests.
// build.gradle.kts// Check the official docs for the current versiondependencies { implementation("com.google.mlkit:genai-summarization:<latest>")}
And the shape of the code will look familiar.
val options = SummarizerOptions.builder(context) .setInputType(InputType.ARTICLE) .setOutputType(OutputType.ONE_BULLET) .build()val summarizer = Summarization.getClient(options)// Ask before using — same role as Chrome's availability()when (summarizer.checkFeatureStatus().await()) { FeatureStatus.UNAVAILABLE -> useCloudFallback() FeatureStatus.DOWNLOADABLE -> summarizer.downloadFeature(downloadCallback) FeatureStatus.DOWNLOADING -> showProgress() FeatureStatus.AVAILABLE -> runSummarization(summarizer)}
The four states from checkFeatureStatus() line up almost exactly with the four from Chrome's availability(). Different platforms, same design philosophy: the model might be here, might not — ask first.
So if this article reduces to one sentence: remembering the door's name matters less than honoring the three steps — check, download, run. The real reason old samples fail isn't the renamed namespace. It's that those three steps were never there. The namespace change just happened to be the part you could see.
Device coverage varies too. GenAI APIs run on hardware with supported chipsets — MediaTek Dimensity, Qualcomm Snapdragon, Google Tensor. Not every Android device qualifies, so treat FeatureStatus.UNAVAILABLE as an ordinary branch rather than an exception. The ML Kit GenAI overview covers the details.
Count the devices you actually reach, before you ship the feature
Reading the requirements table leaves you thinking it's stricter than expected. But what fraction of your own visitors clears it? Guessing doesn't work here, so I wired up the measurement first.
resolveOnDeviceMode() already collapses everything into three values. Send that value once per session and you have the distribution.
// Record the reachable tier once per sessionexport async function reportOnDeviceMode() { if (sessionStorage.getItem('nano:reported')) return; const mode = await resolveOnDeviceMode(); // 'ready' | 'prompt-download' | 'cloud' sessionStorage.setItem('nano:reported', mode); navigator.sendBeacon( '/api/telemetry/on-device', new Blob( [JSON.stringify({ mode, platform: navigator.userAgentData?.platform ?? 'unknown' })], { type: 'application/json' }, ), );}
Nothing device-identifying goes in the payload. The tier and a coarse platform label are enough to decide whether the feature ships. sendBeacon is there so the call survives a user leaving the page.
Here's what resolveOnDeviceMode() returned across the machines I have on hand. Five devices is an anecdote, not a sample — but the shape of the split is instructive.
Device / environment
Result
Why
macOS 14, M2, 180 GB free, Chrome 148
ready
Model already present, all requirements met
Same Mac, free space dropped to 12 GB
cloud
Below the 22 GB bar, availability reports unavailable
Windows 11, integrated GPU (4 GB VRAM), 16 GB RAM
prompt-download
Qualifies but not yet fetched — waiting on the button
Android, Chrome
cloud
LanguageModel doesn't exist at all
iPadOS, Safari
cloud
Same
One machine out of five was usable on first visit. The result that stuck with me is the Mac dropping to cloud purely because I filled the disk. A device that qualifies today can stop qualifying tomorrow, on nothing but free space.
Two decisions came out of that.
First, keep the cloud path as the primary route. On-device is a speed and privacy overlay, not the door into the feature. Make "Processing on your device" the headline of your UI and every visitor who lands on cloud sees a screen you can't explain.
Second, only put the download prompt on screens where the payoff is obvious. You're asking for several minutes and several gigabytes. If the value on the other side isn't visible, people close the tab partway through. Requesting that download on a first visit, for something as quiet as autocomplete, is a hard sell.
Start collecting before the feature goes live. Deciding the UI after you've seen the distribution saves you from building branches nobody walks down.
What to give Nano, and what to keep in the cloud
Once it runs, the question becomes what to hand it.
Nano is small, and its remit is correspondingly narrow. The Prompt API accepts text, image, and audio input, but output is text only. Language support currently covers en / ja / es / de / fr.
Here's where I landed on the split, in the context of my own indie development work.
Dimension
Give it to Nano
Keep it in the cloud (Gemini API)
Latency
Autocomplete, reply suggestions — anything you can't make users wait for
Batch work where seconds are fine
Sensitivity
Classifying notes and chats that shouldn't leave the device
Content meant to be published anyway
Output length
Short summaries, tagging, yes/no judgments
Long-form analysis and reports
Accuracy demands
Suggestions where being wrong is cheap
Extraction and formatting where it isn't
Cost
High-frequency calls you don't want metered
Low-frequency work that needs quality
Work with short, structurally constrained output — classification, tagging — is where Nano is genuinely good. And the Prompt API gives you responseConstraint to pin the output to a JSON Schema.
const session = await LanguageModel.create();const schema = { type: 'boolean' };const result = await session.prompt( `Is the following message a support request?\n\n${text}`, { responseConstraint: schema });console.log(JSON.parse(result)); // true / false
As a classifier, Nano is straightforward to use. Hand it long-form generation and it loses to the cloud on both quality and speed.
One more thing about session design: context fills up as conversations continue.
console.log(`${session.contextUsage} / ${session.contextWindow}`);session.addEventListener('contextoverflow', () => { // Older turns have started getting dropped});
On overflow, the oldest prompt/response pairs are removed first (the system prompt survives). If that still isn't enough, you get a QuotaExceededError. For long conversations, watching contextUsage and cutting the session yourself gives you far more predictable behavior.
Also worth noting: temperature and topK tuning is limited to the Prompt API for Chrome Extensions, or to origin trial participants. From an ordinary web page, those parameters currently do nothing. I spent a while passing values that were never being read.
Passing an image, showing progress — multimodal input and streaming
The Prompt API takes image and audio input, but you have to declare it. Hand it a Blob without listing image in expectedInputs and it fails at runtime with NotSupportedError.
const session = await LanguageModel.create({ expectedInputs: [ { type: 'text', languages: ['en'] }, { type: 'image' }, // omit this and it throws at run time ], expectedOutputs: [{ type: 'text', languages: ['en'] }],});const blob = await fetch('/receipt.png').then((r) => r.blob());const store = await session.prompt([ { role: 'user', content: [ { type: 'text', value: 'Return only the store name shown in this image.' }, { type: 'image', value: blob }, ], },]);
Unlike a text-only call, content becomes an array. I once left it as a plain string and the image was silently dropped — no error, just an answer that ignored the picture. That kind of mistake takes a while to spot.
For longer output, switch to promptStreaming(). Three seconds of summarization feels considerably longer when nothing moves on screen.
const stream = session.promptStreaming(`Summarize this article in three lines.\n\n${text}`);for await (const chunk of stream) { output.append(chunk); // chunks are deltas, not the accumulated text}
Chunks arrive as deltas. Older builds re-sent the full text each time, so migrated code that still concatenates ends up printing everything twice. Check that path when you move over.
On my machine the first character of a three-line summary appeared in roughly 0.4 seconds. Total time doesn't change, but the sense of waiting mostly disappears. Short classifications get prompt(), anything the user reads gets promptStreaming() — worth deciding early.
What I measured on one machine — download and inference time
You can't make the call without numbers, so here are reference figures from a single machine in my own indie setup. Not a rigorous benchmark — just enough to feel the order of magnitude (macOS 14, M2, shared VRAM, Chrome 148).
Phase
Measured on my machine (reference)
First model download
~3–6 min (varies with connection and time of day)
create() init (model already fetched)
0.3–0.8 s
Short classification prompt (one boolean)
120–300 ms
Three-line summary (800-char input)
1.5–3 s
Same classification via Gemini Flash (cloud round-trip)
400–900 ms
Two things stood out. First, for a short judgment, Nano beats a cloud round-trip — with the network hop gone, short judgments felt roughly 2x faster and the wait simply disappeared. Second, as output grows the gap narrows and then flips: for summarization and other longer outputs, the larger cloud model was both faster and better.
So if speed is your reason for choosing Nano, keep it to short, high-frequency work. Push long-form generation on-device and the optimization turns into a liability.
Init cost matters too. create() runs per session, so rebuilding one for every classification stacks up the initialization overhead. Holding a single reusable session and repeating only prompt() proved far steadier.
Don't swallow the exceptions — turn three errors into branches
Write only the happy path and the worst production symptom is "it worked yesterday, today nothing happens, silently." With on-device AI that's an everyday possibility: drop below 10 GB and the model is evicted; overflow the context and an exception fires.
So shape the prompt() call to distinguish three errors, and the failure modes become legible.
async function runOnDevice(session, text) { try { return await session.prompt(text); } catch (err) { // 1. Context overflow — tear the session down and rebuild if (err.name === 'QuotaExceededError') { session.destroy(); return { retry: true, reason: 'context-full' }; } // 2. Model vanished at runtime — re-read the state if (err.name === 'NotSupportedError' || err.name === 'InvalidStateError') { const state = await LanguageModel.availability(); if (state !== 'available') return fallbackToCloud(text); } // 3. Anything else: don't swallow it, fall back to cloud return fallbackToCloud(text); }}
The point is that every error path ends back at the cloud. On-device is an optimization for speed and privacy, not the sole door to a feature. Narrow it to one entrance and the moment the model is evicted, the feature itself disappears.
And you can't predict the moment of eviction. That's why calling availability() not just on first run but again on failure pays off. Extend "ask before you use it" into "ask again when it breaks." That one extra step turns a silent defect into a visible branch.
Where to go next
If your code isn't running, check in this order.
Open chrome://on-device-internals and see whether the model is there
If it isn't, console.log the raw return value of availability()
If it says 'downloadable', move create() inside a button's click handler
Hunting down ai.-prefixed calls can wait. A renamed namespace announces itself; a missing availability() fails in silence.
And there's one thing worth deciding before you write any of it: what your app shows on a device that can't run the feature at all. Settle that first, and the 22 GB requirement and the below-10-GB eviction both stop being surprises and become branches you already handle.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.