Zero-Latency On-Device AI in React: Chrome Gemini Nano & Prompt API in Productie
Bouw razendsnelle, privacyvriendelijke React-interfaces met Chrome ingebouwde Gemini Nano API's, window.LanguageModel, lokale PII-opschoning en robuuste hybride fallbacks.

Zero-Latency On-Device AI in React: Chrome Gemini Nano & Prompt API in Productie
Elke toetsaanslag in een moderne webapplicatie brengt de verwachting van onmiddellijke feedback met zich mee. Wanneer teams grote taalmodellen koppelen aan tekstvelden voor automatische aanvulling, toonaanpassing of realtime samenvattingen, stuiten ze op een harde fysieke beperking: netwerklatentie.
Een retourtje van een browser naar een cloud-LLM-gateway kost tussen de 300 ms en 1500 ms voor de eerste token (TTFT). Die vertraging is acceptabel voor een apart chatvenster, maar verstoort de typeflow in tekstverwerkers en invoerformulieren. Cloudinferentie brengt bovendien terugkerende kosten per token, koude starts, rate limits en compliancerisico's met zich mee zodra gevoelige gebruikersdata de client verlaat.
Ingebouwde AI in Chrome verandert dit fundament door Gemini Nano direct in de browser-runtime in te bedden. Dit artikel behandelt de architectuur, de sessiecyclus, databescherming op de client en concrete React-integratiepatronen voor het draaien van lokale modellen in productie.
+-------------------------------------------------------------------------+
| Browser Context (Main Thread) |
| |
| [ User Input ] ---> [ Local PII Sanitizer ] ---> [ useBrowserAI Hook ] |
| | | |
| v v |
| (Cleaned Prompt) (Streaming Tokens) |
| | |
| v |
| [ <SmartTextArea /> ] |
+------------------------------------------------------------|------------+
| IPC / Mojo
+------------------------------------------------------------v------------+
| Chrome Optimization Guide Process (On-Device Runtime) |
| |
| [ window.LanguageModel / ai ] <---> [ Gemini Nano Neural Weights ] |
| - Sub-20ms TTFT - Zero Network Egress |
| - Local Session Cache - GPU/NPU Silicon (LiteRT/Metal) |
+-------------------------------------------------------------------------+Het inferentiemodel in de browser
Chrome levert Gemini Nano als een lokaal neuraal basismodel dat wordt beheerd door de Optimization Guide-service van de browser. In tegenstelling tot WebAssembly- of WebGPU-implementaties waarbij gebruikers bij de eerste paginaweergave 1 GB tot 4 GB aan modelgewichten moeten downloaden, beheert Chrome de distributie op de achtergrond via de Component Updater.
De inferentie draait in een afzonderlijk hulpproces, geïsoleerd van webcontent-scripts. De JavaScript-uitvoeringscontext communiceert met het model via gestructureerde interprocescommunicatie (IPC) via de standaard window.LanguageModel-global (en de overgangsnamespace window.ai.languageModel).
Architecturale eigenschappen
- Gegarandeerde privacy: Ruwe prompttekst verlaat het apparaat nooit via een netwerksocket. Voor gereguleerde sectoren zoals de gezondheidszorg en financiën voldoet lokale inferentie aan strenge eisen voor nul-data-egress.
- TTFT onder 20 ms: Omdat het model in het lokale geheugen staat en hardwareversnellers op het apparaat benut (Apple Silicon Metal, Windows DirectML, Vulkan), start tokengeneratie vrijwel direct.
- Geen variabele infrastructuurkosten: Berekeningen worden uitgevoerd op de processor van de client. Een piek van honderdduizenden gebruikers veroorzaakt 0 $ aan API-inferentiekosten.
- Offline inzetbaar: Functionaliteiten blijven volledig werken bij haperende mobiele verbindingen of volledige internetstoringen.
De levenscyclus van Chrome ingebouwde AI
De Prompt API is geen eenvoudige stateloze functie. Deze is gebaseerd op een stateful sessiemodel dat expliciete controles op compatibiliteit, parameterafstemming en geheugenbeheer vereist.
// Typedefinities voor de W3C Prompt API
export type AICapabilityAvailability = "readily" | "after-download" | "no";
export interface AICapabilities {
available: AICapabilityAvailability;
defaultTemperature?: number;
maxTemperature?: number;
defaultTopK?: number;
maxTopK?: number;
}
export interface AILanguageModelCreateOptions {
systemPrompt?: string;
initialPrompts?: Array<{ role: "system" | "user" | "assistant"; content: string }>;
temperature?: number;
topK?: number;
signal?: AbortSignal;
monitor?: (monitor: EventTarget) => void;
}
export interface AILanguageModelSession {
prompt(input: string, options?: { signal?: AbortSignal }): Promise<string>;
promptStreaming(input: string, options?: { signal?: AbortSignal }): ReadableStream<string>;
countPromptTokens(input: string): Promise<number>;
maxTokens: number;
tokensSoFar: number;
tokensLeft: number;
topK: number;
temperature: number;
clone(): Promise<AILanguageModelSession>;
destroy(): void;
}
export interface AILanguageModelFactory {
availability?(): Promise<AICapabilityAvailability>;
capabilities?(): Promise<AICapabilities>;
create(options?: AILanguageModelCreateOptions): Promise<AILanguageModelSession>;
}
declare global {
interface Window {
LanguageModel?: AILanguageModelFactory;
ai?: {
languageModel?: AILanguageModelFactory;
};
}
}Drie toestanden van modelbeschikbaarheid
Voordat een sessie wordt geïnitialiseerd, moeten applicaties window.LanguageModel (of window.ai.languageModel) aanroepen. De browser retourneert een van de volgende drie statussen:
"readily": Het modelbestand is lokaal op schijf opgeslagen en direct gereed voor initialisatie."after-download": Het apparaat voldoet aan de hardwarevereisten, maar het modelbestand staat in de wachtrij om op de achtergrond te worden gedownload. Applicaties kunnen de voortgang volgen via demonitor-callback."no": Het apparaat beschikt niet over de juiste hardware (onvoldoende VRAM/RAM of niet-ondersteunde GPU) of de browseroptie staat uitgeschakeld.
PII-opschoningslaag op de client
Zelfs bij gebruik van een lokaal model zonder netwerkverbinding vereist goede beveiliging dat gevoelige gegevens (creditcardnummers, persoonsnummers, e-mailadressen, authenticatietokens) worden gemaskeerd voordat de prompt wordt samengesteld. Dit voorkomt dat gegevens tussen sessies worden vervuild.
Hier is een compact tokenisatiefilter zonder externe afhankelijkheden:
// lib/pii-scrubber.ts
interface ScrubRule {
name: string;
pattern: RegExp;
mask: (match: string) => string;
}
const PII_RULES: ScrubRule[] = [
{
name: "CREDIT_CARD",
pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
mask: () => "[REDACTED_CARD]",
},
{
name: "EMAIL",
pattern: /[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g,
mask: () => "[REDACTED_EMAIL]",
},
{
name: "PHONE",
pattern: /\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
mask: () => "[REDACTED_PHONE]",
},
{
name: "AUTH_TOKEN",
pattern: /\b(ey[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*)|(ghp_[A-Za-z0-9]{36})\b/g,
mask: () => "[REDACTED_TOKEN]",
},
{
name: "SSN",
pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
mask: () => "[REDACTED_SSN]",
},
];
export interface ScrubResult {
sanitizedText: string;
redactionCount: number;
detectedTypes: string[];
}
export function sanitizePromptText(input: string): ScrubResult {
let sanitizedText = input;
let redactionCount = 0;
const detectedTypes: string[] = [];
for (const rule of PII_RULES) {
const matches = sanitizedText.match(rule.pattern);
if (matches && matches.length > 0) {
redactionCount += matches.length;
detectedTypes.push(rule.name);
sanitizedText = sanitizedText.replace(rule.pattern, rule.mask);
}
}
return { sanitizedText, redactionCount, detectedTypes };
}Productieklare React Hook: useBrowserAI
Het handmatig beheren van modelsessies in React-componenten leidt tot geheugenlekken als sessies niet worden vernietigd bij het unmounten. De useBrowserAI-hook beheert de levenscyclus, capaciteitscontroles, streaming tekstverwerking en abort-signalen.
// hooks/useBrowserAI.ts
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import type {
AICapabilityAvailability,
AILanguageModelSession,
AILanguageModelCreateOptions,
} from "@/types/chrome-ai";
interface UseBrowserAIOptions extends AILanguageModelCreateOptions {
autoInit?: boolean;
}
interface UseBrowserAIReturn {
availability: AICapabilityAvailability | "checking" | "unsupported";
downloadProgress: number | null;
isGenerating: boolean;
error: string | null;
generateText: (promptText: string) => Promise<string>;
streamText: (promptText: string, onChunk: (chunk: string) => void) => Promise<string>;
abort: () => void;
resetSession: () => Promise<void>;
tokensRemaining: number | null;
}
export function useBrowserAI(options: UseBrowserAIOptions = {}): UseBrowserAIReturn {
const { systemPrompt, temperature = 0.7, topK = 3, autoInit = true } = options;
const [availability, setAvailability] = useState<AICapabilityAvailability | "checking" | "unsupported">("checking");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [tokensRemaining, setTokensRemaining] = useState<number | null>(null);
const sessionRef = useRef<AILanguageModelSession | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const cleanupSession = useCallback(() => {
if (sessionRef.current) {
try {
sessionRef.current.destroy();
} catch (err) {
console.warn("Error destroying previous session:", err);
}
sessionRef.current = null;
}
}, []);
const initSession = useCallback(async () => {
if (typeof window === "undefined") return;
const factory = (window as any).LanguageModel || (window as any).ai?.languageModel;
if (!factory) {
setAvailability("unsupported");
return;
}
try {
let availStatus: AICapabilityAvailability = "readily";
if (typeof factory.availability === "function") {
availStatus = await factory.availability();
} else if (typeof factory.capabilities === "function") {
const caps = await factory.capabilities();
availStatus = caps.available;
}
setAvailability(availStatus);
if (availStatus === "no") {
return;
}
cleanupSession();
const session = await factory.create({
systemPrompt,
temperature,
topK,
monitor(m: EventTarget) {
m.addEventListener("downloadprogress", (e: Event) => {
const customEvent = e as CustomEvent<{ loaded: number; total: number }>;
if (customEvent.detail && customEvent.detail.total > 0) {
const progress = Math.round((customEvent.detail.loaded / customEvent.detail.total) * 100);
setDownloadProgress(progress);
}
});
},
});
sessionRef.current = session;
setTokensRemaining(session.tokensLeft ?? 4096);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to initialize Browser AI";
setError(message);
cleanupSession();
}
}, [systemPrompt, temperature, topK, cleanupSession]);
useEffect(() => {
if (autoInit) {
initSession();
}
return () => {
cleanupSession();
};
}, [autoInit, initSession, cleanupSession]);
const abort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsGenerating(false);
}, []);
const streamText = useCallback(
async (promptText: string, onChunk: (chunk: string) => void): Promise<string> => {
if (!sessionRef.current) {
throw new Error("Session is not initialized");
}
abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsGenerating(true);
setError(null);
let accumulatedResponse = "";
try {
const stream = sessionRef.current.promptStreaming(promptText, {
signal: controller.signal,
});
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
accumulatedResponse = value;
onChunk(value);
}
}
if (sessionRef.current) {
setTokensRemaining(sessionRef.current.tokensLeft);
}
return accumulatedResponse;
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return accumulatedResponse;
}
const errorMsg = err instanceof Error ? err.message : "Generation failed";
setError(errorMsg);
throw err;
} finally {
setIsGenerating(false);
abortControllerRef.current = null;
}
},
[abort]
);
const generateText = useCallback(
async (promptText: string): Promise<string> => {
if (!sessionRef.current) {
throw new Error("Session is not initialized");
}
abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsGenerating(true);
setError(null);
try {
const result = await sessionRef.current.prompt(promptText, {
signal: controller.signal,
});
if (sessionRef.current) {
setTokensRemaining(sessionRef.current.tokensLeft);
}
return result;
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return "";
}
const errorMsg = err instanceof Error ? err.message : "Generation failed";
setError(errorMsg);
throw err;
} finally {
setIsGenerating(false);
abortControllerRef.current = null;
}
},
[abort]
);
return {
availability,
downloadProgress,
isGenerating,
error,
generateText,
streamText,
abort,
resetSession: initSession,
tokensRemaining,
};
}Uitgelicht component: <SmartTextArea />
Het onderstaande component combineert lokale ghost-tekstaanvulling met toonevaluatie en een statusindicatie voor offlinemodus. Zodra de gebruiker even pauzeert tijdens het typen, voorspelt Gemini Nano de rest van de zin. Met de Tab-toets wordt de suggestie direct geaccepteerd zonder de invoerfocus te verliezen.
// components/SmartTextArea.tsx
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useBrowserAI } from "@/hooks/useBrowserAI";
import { sanitizePromptText } from "@/lib/pii-scrubber";
interface SmartTextAreaProps {
initialValue?: string;
placeholder?: string;
onChange?: (value: string) => void;
debounceMs?: number;
}
type SentimentTone = "positive" | "constructive" | "neutral" | "urgent" | "analyzing";
export function SmartTextArea({
initialValue = "",
placeholder = "Draft your architectural decision record or engineering notes...",
onChange,
debounceMs = 280,
}: SmartTextAreaProps) {
const [text, setText] = useState(initialValue);
const [suggestion, setSuggestion] = useState("");
const [tone, setTone] = useState<SentimentTone>("neutral");
const [isOffline, setIsOffline] = useState(!navigator.onLine);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// Initialize the local completion session with a constrained system prompt
const {
availability,
downloadProgress,
isGenerating,
generateText,
abort,
tokensRemaining,
} = useBrowserAI({
systemPrompt:
"You are an autocompletion engine for software engineers. Provide a short, direct inline continuation (1 to 8 words) for the user's text. Return ONLY the continuation words. Do not repeat the input.",
temperature: 0.2,
topK: 1,
});
// Track browser connectivity
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
// Request completion from local model
const triggerCompletion = useCallback(
async (currentText: string) => {
if (availability !== "readily" || currentText.trim().length < 8) {
setSuggestion("");
return;
}
// Sanitize input to protect sensitive data locally
const { sanitizedText } = sanitizePromptText(currentText);
try {
const rawPrediction = await generateText(
`Text: "${sanitizedText}"\nContinuation:`
);
const cleanPrediction = rawPrediction
.replace(/^["'\s]+|["'\s]+$/g, "")
.trim();
if (cleanPrediction.length > 0) {
setSuggestion(cleanPrediction);
} else {
setSuggestion("");
}
} catch {
setSuggestion("");
}
},
[availability, generateText]
);
// Debounced input handler
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value;
setText(newText);
setSuggestion("");
abort();
if (onChange) {
onChange(newText);
}
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
triggerCompletion(newText);
}, debounceMs);
};
// Keyboard navigation for ghost text acceptance
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Tab" && suggestion.length > 0) {
e.preventDefault();
const mergedText = text.endsWith(" ")
? text + suggestion
: text + " " + suggestion;
setText(mergedText);
setSuggestion("");
if (onChange) onChange(mergedText);
} else if (e.key === "Escape") {
setSuggestion("");
abort();
}
};
return (
<div className="smart-textarea-container" style={{ position: "relative", width: "100%" }}>
{/* Header telemetry and indicators */}
<div
className="telemetry-bar"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "8px",
fontSize: "12px",
fontFamily: "monospace",
}}
>
<div style={{ display: "flex", gap: "12px", alignItems: "center" }}>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
color: availability === "readily" ? "#15803d" : "#b45309",
}}
>
<span
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: availability === "readily" ? "#22c55e" : "#f59e0b",
}}
/>
{availability === "readily"
? "Gemini Nano (Local Engine Active)"
: availability === "after-download"
? `Downloading Weights (${downloadProgress || 0}%)`
: "Chrome AI Unavailable (Fallback Mode)"}
</span>
{tokensRemaining !== null && (
<span style={{ color: "#64748b" }}>
Budget: {tokensRemaining} tokens left
</span>
)}
</div>
<div style={{ display: "flex", gap: "8px" }}>
{isOffline && (
<span
style={{
backgroundColor: "#fef3c7",
color: "#92400e",
padding: "2px 8px",
borderRadius: "4px",
}}
>
Offline Mode
</span>
)}
</div>
</div>
{/* Editor overlay stack */}
<div style={{ position: "relative", minHeight: "160px" }}>
{/* Ghost text display layer */}
<div
aria-hidden="true"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: "12px",
fontFamily: "inherit",
fontSize: "14px",
lineHeight: "1.5",
pointerEvents: "none",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "transparent",
border: "1px solid transparent",
}}
>
<span>{text}</span>
{suggestion && (
<span style={{ color: "#94a3b8", opacity: 0.8 }}>
{text.endsWith(" ") ? "" : " "}
{suggestion}
</span>
)}
</div>
{/* User interactive input */}
<textarea
ref={textareaRef}
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label="Smart Content Editor"
style={{
width: "100%",
minHeight: "160px",
padding: "12px",
fontSize: "14px",
lineHeight: "1.5",
fontFamily: "inherit",
backgroundColor: "transparent",
border: "1px solid #cbd5e1",
borderRadius: "6px",
resize: "vertical",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Footer controls and keyboard hints */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "6px",
fontSize: "12px",
color: "#64748b",
}}
>
<span>
{suggestion ? "Press [Tab] to accept completion, [Esc] to dismiss" : "Type to see inline local completions"}
</span>
{isGenerating && <span>Generating prediction...</span>}
</div>
</div>
);
}Prestaties en latentievergelijking
Om de concrete verbeteringen in gebruikerservaring te meten, hebben we Gemini Nano in Chrome vergeleken met een standaard cloud-API-opzet (Gemini Flash gehost in een centrale regio via een Next.js Edge route).
Empirische vergelijking van latentie en belasting
| Evaluatiemetriek | Chrome Built-in AI (Gemini Nano) | Cloud API via Next.js Edge |
|---|---|---|
| Time-to-First-Token (TTFT) | 12 ms - 24 ms | 420 ms - 980 ms |
| Netwerkegress per verzoek | 0 KB (Geen netwerk-I/O) | 1,4 KB - 8,2 KB |
| PII-blootstellingsrisico | Geen risico (Blijft in lokaal geheugen) | Vereist TLS-transport en verwerkersovereenkomst |
| Operationele kosten per eenheid | 0,00 $ / miljoen verzoeken | 0,15 / miljoen tokens |
| Offline inzetbaarheid | Volledig functioneel | Mislukt direct (HTTP 503 / Netwerkfout) |
| Hardwarebelasting | ~400 MB VRAM / RAM | Geen geheugenbelasting op client |
Valkuilen in productie en architecturale richtlijnen
Het draaien van neurale modellen op apparaten van gebruikers introduceert uitdagingen die niet voorkomen bij traditionele microservices.
1. Uitputting van het contextvenster en geheugenbeheer
Gemini Nano heeft een vastgesteld tokenbudget (meestal 1024 tot 4096 tokens per sessie afhankelijk van het apparaat). Als een sessie voor meerdere lange prompts wordt hergebruikt zonder reset, daalt session.tokensLeft naar nul en genereren volgende aanroepen een InvalidStateError.
Oplossing: Gebruik session.clone() om kortstondige werksessies aan te maken voor specifieke taken en roep direct na ontvangst van het resultaat session.destroy() aan.
// Pattern: Ephemeral session cloning
async function runIsolatedTask(
baseSession: AILanguageModelSession,
taskPrompt: string
): Promise<string> {
const ephemeralSession = await baseSession.clone();
try {
return await ephemeralSession.prompt(taskPrompt);
} finally {
ephemeralSession.destroy(); // Free underlying neural runtime resources
}
}2. Hybride fallback-architectuur
Niet elke bezoeker beschikt over een browser met geactiveerde hardwareversnelling voor lokale modellen. Daarom moet een productiearchitectuur progressieve verbetering toepassen:
- Laag 1 (Lokaal): Controleer
window.LanguageModel(ofwindow.ai.languageModel). Indien"readily", verwerk lokaal met een latentie onder 20 ms en zonder serverbelasting. - Laag 2 (Edge fallback): Als
capabilities.available === "no", stuur het verzoek door naar een Next.js Server Action of Edge Route met een cloudmodel. - Laag 3 (Beheerste degradatie): Bij volledige afwezigheid van internet en zonder lokale AI, val terug op eenvoudige regels zonder fatale runtimefouten.
Samenvatting
Het verplaatsen van inferentie van externe datacenters naar de browser zelf lost het latentieprobleem bij interactieve webinterfaces definitief op. Door de Prompt API van Chrome te combineren met PII-filtering, doordachte React-hooks en hybride fallback-strategieën kunnen frontendteams snelle, veilige en betrouwbare webapplicaties leveren.