IA On-Device de Latencia Cero en React: Chrome Gemini Nano y Prompt API en Producción
Construcción de interfaces React sin latencia y con privacidad total mediante las APIs integradas de Gemini Nano en Chrome, window.LanguageModel, saneamiento local de PII y fallbacks híbridos.

IA On-Device de Latencia Cero en React: Chrome Gemini Nano y Prompt API en Producción
Cada pulsación de tecla en una aplicación web moderna exige una respuesta instantánea. Cuando los equipos integran modelos de lenguaje en campos de texto para autocompletar, ajustar el tono o resumir en tiempo real, se topan con un límite físico ineludible: el tiempo de ida y vuelta en red.
Una petición desde el navegador hasta una pasarela de LLM en la nube tarda entre 300 ms y 1500 ms en emitir el primer token (TTFT). Esa latencia resulta aceptable en un panel de chat independiente, pero interrumpe el ritmo de escritura en editores y formularios. La inferencia en la nube también acarrea costes recurrentes por token, arranques en frío, límites de frecuencia y riesgos de cumplimiento normativo cuando los datos sensibles del usuario salen del cliente.
La IA integrada en Chrome transforma este escenario al incrustar Gemini Nano directamente en el entorno de ejecución del navegador. Este artículo analiza la arquitectura, el ciclo de vida de las sesiones, el saneamiento de datos en el cliente y los patrones de integración en React necesarios para utilizar modelos locales en entornos de producción.
+-------------------------------------------------------------------------+
| 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) |
+-------------------------------------------------------------------------+El modelo de inferencia en el navegador
Chrome distribuye Gemini Nano como un modelo neuronal fundacional on-device gestionado por el servicio Optimization Guide del navegador. A diferencia de las soluciones basadas en WebAssembly o WebGPU, que obligan a descargar entre 1 GB y 4 GB de pesos en la primera carga de página, Chrome gestiona la distribución del binario en segundo plano a través de su Component Updater.
La inferencia se ejecuta en un proceso auxiliar aislado de los scripts de contenido web. El contexto de ejecución JavaScript interactúa con el modelo mediante comunicación entre procesos (IPC) estructurada a través de la API estándar window.LanguageModel (y el espacio transitorio window.ai.languageModel).
Características arquitectónicas
- Privacidad determinista: El texto original del prompt nunca atraviesa un socket de red. Para sectores regulados como la salud o las finanzas, la inferencia local cumple políticas estrictas de cero salida de datos.
- TTFT inferior a 20 ms: Dado que el modelo reside en la memoria local y aprovecha los aceleradores de hardware del dispositivo (Apple Silicon Metal, Windows DirectML, Vulkan), la generación de tokens comienza prácticamente al instante.
- Cero coste variable de infraestructura: Las cargas de trabajo se procesan en el silicio del cliente. Picos de cientos de miles de usuarios generan un coste de 0 $ en llamadas de inferencia.
- Capacidad offline: Las funciones siguen operativas ante conexiones móviles intermitentes o cortes totales de internet.
Ciclo de vida de la IA integrada en Chrome
La Prompt API no es una simple llamada a función sin estado. Se apoya en un modelo de sesión con estado que exige comprobaciones explícitas de compatibilidad, ajuste de parámetros y gestión de memoria.
// Definiciones de tipos para la 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;
};
}
}Tres estados de disponibilidad del modelo
Antes de inicializar una sesión, las aplicaciones deben consultar window.LanguageModel (o window.ai.languageModel). El navegador devuelve uno de los siguientes tres estados:
"readily": El binario del modelo está almacenado en el disco local y listo para instanciarse de inmediato."after-download": El dispositivo cumple los requisitos de hardware, pero el binario está en cola de descarga en segundo plano. Las aplicaciones pueden monitorizar el progreso con el callbackmonitor."no": El dispositivo carece de soporte por hardware (VRAM o RAM insuficiente, arquitectura de GPU no compatible) o la bandera de configuración está desactivada.
Capa de saneamiento de PII en el cliente
Incluso al trabajar con un modelo local sin salida a red, las buenas prácticas de seguridad exigen enmascarar identificadores sensibles (tarjetas de crédito, documentos de identidad, correos electrónicos, tokens de acceso) antes de construir el prompt. Esto evita contaminación cruzada en historiales de sesión compartidos.
A continuación se muestra un filtro de tokenización ligero y sin dependencias externas:
// 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 };
}Hook de React para producción: useBrowserAI
Gestionar sesiones de modelos manualmente dentro de componentes de React provoca fugas de memoria si no se destruyen al desmontar el componente. El hook useBrowserAI encapsula el ciclo de vida, la comprobación de capacidades, la agregación de texto en streaming y el control de señales de cancelación.
// 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,
};
}Componente destacado: <SmartTextArea />
Este componente combina la sugerencia de texto fantasma con análisis de tono en directo y un indicador de estado sin conexión. Cuando el usuario hace una pausa al escribir, Gemini Nano predice la continuación de la frase. Al pulsar la tecla Tab, la sugerencia se incorpora de inmediato sin perder el foco.
// 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>
);
}Comparativa de rendimiento y latencia
Para cuantificar las mejoras reales en la experiencia de usuario, evaluamos Gemini Nano en Chrome frente a un despliegue convencional con API en la nube (Gemini Flash servido desde una región central mediante una ruta Next.js Edge).
Comparación empírica de latencia y recursos
| Métrica de evaluación | Chrome Built-in AI (Gemini Nano) | API en la nube vía Next.js Edge |
|---|---|---|
| Tiempo hasta el primer token (TTFT) | 12 ms - 24 ms | 420 ms - 980 ms |
| Salida de red por petición | 0 KB (Cero I/O de red) | 1,4 KB - 8,2 KB |
| Riesgo de exposición de PII | Cero riesgo (Permanece en memoria local) | Requiere salida TLS y contrato DPA |
| Coste unitario operativo | 0,00 $ / millón de peticiones | 0,15 / millón de tokens |
| Resiliencia sin conexión | Funcionalidad completa | Falla (HTTP 503 / Error de red) |
| Consumo de hardware | ~400 MB VRAM / RAM | Cero consumo de memoria en cliente |
Retos en producción y consideraciones arquitectónicas
Ejecutar modelos fundacionales en el hardware del cliente presenta desafíos específicos que no existen en microservicios tradicionales.
1. Agotamiento de la ventana de contexto y gestión de memoria
Gemini Nano tiene un límite de tokens acotado (habitualmente entre 1024 y 4096 tokens por sesión según el dispositivo). Si una sesión se reutiliza a lo largo de múltiples textos largos sin reiniciarse, session.tokensLeft llega a cero y las llamadas posteriores emiten un error InvalidStateError.
Solución: Utilice session.clone() para generar sesiones de trabajo efímeras destinadas a tareas puntuales y ejecute session.destroy() en cuanto obtenga la respuesta.
// 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. Arquitectura de respaldo híbrida
No todos los visitantes dispondrán de un navegador con aceleración por hardware para modelos locales. Por ello, una arquitectura orientada a producción debe aplicar mejora progresiva:
- Nivel 1 (Local): Consultar
window.LanguageModel(owindow.ai.languageModel). Si devuelve"readily", procesar localmente con latencia inferior a 20 ms y sin carga de servidor. - Nivel 2 (Fallback en el Edge): Si
capabilities.available === "no", derivar la petición a una Server Action o Edge Route en Next.js con un modelo en la nube. - Nivel 3 (Degradación controlada): En ausencia total de conexión y sin soporte de IA local, recurrir a reglas heurísticas sin provocar excepciones no controladas.
Conclusiones prácticas
Llevar la inferencia desde servidores remotos hasta el propio navegador suprime la fricción de latencia en interfaces interactivas. Al combinar la Prompt API de Chrome con un filtrado estricto de PII en el cliente, hooks de React bien estructurados y estrategias de respaldo en el edge, los equipos de frontend pueden ofrecer aplicaciones inmediatas, privadas y resilientes en cualquier entorno.