DEV Community

Cover image for Metí Gemma corriendo en el browser, sin API keys, y me cambió cómo pienso el edge
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

Metí Gemma corriendo en el browser, sin API keys, y me cambió cómo pienso el edge

Hay una creencia instalada en la comunidad dev sobre AI en producción que está, con todo respeto, bastante equivocada: que para meter un LLM en tu app necesitás sí o sí una API key, un server que haga la inferencia, y alguien que pague la factura de OpenAI a fin de mes. La arquitectura por default en 2025 es: frontend → API call → cloud → respuesta. Siempre. Sin excepción.

Mentira.

La semana pasada corrí Gemma — el modelo abierto de Google — directo en el browser. Sin API keys. Sin servidor. Sin latencia de red. El modelo bajó, se cargó en memoria del cliente, y la inferencia corrió ahí mismo, en el dispositivo del usuario. Y en el momento en que vi la primera respuesta generarse sin que ningún request saliera a la red... pará. Esto cambia todo.

Gemma LLM browser sin API keys: qué es y por qué importa

Antes de entrar al código, contexto rápido para los que no siguieron el post anterior sobre meter un LLM chico en Next.js.

Gemma es la familia de modelos open-weights de Google DeepMind. Los modelos chicos — Gemma 2B, Gemma 3 1B — tienen un tamaño razonable para correr en hardware de consumo. Lo nuevo en 2025 es que con WebGPU y las librerías correctas, ese "hardware de consumo" incluye el browser del usuario.

Las herramientas que hacen posible esto:

  • WebGPU API: acceso directo a la GPU desde el browser, sin plugins
  • @huggingface/transformers.js: port de Transformers para el browser, WebAssembly + WebGPU
  • MediaPipe LLM Inference API: el approach de Google, optimizado para Gemma específicamente

Yo probé con Transformers.js porque ya tenía experiencia con el ecosistema Hugging Face y porque el modelo de distribución — cargar pesos desde CDN con cache del browser — me pareció el más práctico para un contexto de app real.

El experimento: código real, sin magia

Empecé simple. Componente de React, sin server, inferencia en el cliente. Este es el código que realmente corrí:

// components/GemmaLocal.tsx
// Inferencia completamente en el browser — sin API calls
'use client';

import { useState, useEffect, useRef } from 'react';

// Importamos pipeline de transformers.js — corre en el browser
import { pipeline, TextGenerationPipeline } from '@huggingface/transformers';

type EstadoCarga = 'idle' | 'cargando' | 'listo' | 'error';

export function GemmaLocal() {
  const [estado, setEstado] = useState<EstadoCarga>('idle');
  const [progreso, setProgreso] = useState(0);
  const [respuesta, setRespuesta] = useState('');
  const [input, setInput] = useState('');
  const pipelineRef = useRef<TextGenerationPipeline | null>(null);

  const cargarModelo = async () => {
    setEstado('cargando');

    try {
      // Gemma 2B instruct — ~1.5GB en el primer load, cacheado después
      // El modelo se descarga una vez y queda en Cache Storage del browser
      pipelineRef.current = await pipeline(
        'text-generation',
        'Xenova/gemma-2b-it', // versión cuantizada, más liviana
        {
          // Usa WebGPU si está disponible, fallback a WASM
          device: 'webgpu',
          progress_callback: (info: { progress?: number }) => {
            if (info.progress) {
              setProgreso(Math.round(info.progress));
            }
          },
        }
      );

      setEstado('listo');
    } catch (error) {
      console.error('Error cargando Gemma:', error);
      setEstado('error');
    }
  };

  const generarRespuesta = async () => {
    if (!pipelineRef.current || !input.trim()) return;

    setRespuesta('');

    // Template de Gemma instruct — importante para que responda bien
    const prompt = `<start_of_turn>user\n${input}<end_of_turn>\n<start_of_turn>model\n`;

    const resultado = await pipelineRef.current(prompt, {
      max_new_tokens: 256,
      // Streaming: cada token se emite apenas se genera
      // La respuesta aparece progresivamente sin esperar al servidor
      callback_function: (output: Array<{ generated_text: string }>) => {
        const texto = output[0]?.generated_text ?? '';
        // Extraemos solo la parte del modelo, sin el prompt
        const respuestaPura = texto.split('<start_of_turn>model\n').pop() ?? '';
        setRespuesta(respuestaPura);
      },
    });

    return resultado;
  };

  return (
    <div className="p-6 max-w-2xl mx-auto">
      {estado === 'idle' && (
        <button
          onClick={cargarModelo}
          className="px-4 py-2 bg-blue-600 text-white rounded"
        >
          Cargar Gemma (primera vez: ~1.5GB)
        </button>
      )}

      {estado === 'cargando' && (
        <div>
          <p>Descargando modelo... {progreso}%</p>
          {/* Después del primer load esto no aparece — el browser lo cachea */}
          <p className="text-sm text-gray-500">
            Solo la primera vez. Después va al instante.
          </p>
        </div>
      )}

      {estado === 'listo' && (
        <div className="space-y-4">
          <textarea
            value={input}
            onChange={(e) => setInput(e.target.value)}
            className="w-full p-3 border rounded"
            placeholder="Tu pregunta..."
            rows={3}
          />
          <button
            onClick={generarRespuesta}
            className="px-4 py-2 bg-green-600 text-white rounded"
          >
            Generar (sin internet)
          </button>
          {respuesta && (
            <div className="p-4 bg-gray-50 rounded">
              <p className="whitespace-pre-wrap">{respuesta}</p>
            </div>
          )}
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/demo-local/page.tsx
// Página standalone — zero server components necesarios para la inferencia
import { GemmaLocal } from '@/components/GemmaLocal';

export default function DemoLocalPage() {
  return (
    <main>
      <h1>Gemma en el browser  inferencia 100% local</h1>
      {/* Este componente no hace ningún fetch a ningún server nuestro */}
      <GemmaLocal />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Lo que pasó: primera carga, ~1.5GB de descarga (modelo cuantizado en 4-bit). Lento. Pero después del primer load, el browser lo cachea en Cache Storage. Segunda visita: el modelo está ahí, se carga en segundos.

Y la inferencia: en una máquina con GPU discreta, entre 5-15 tokens por segundo. En la mía, con una RTX 3060, llegué a 20 tokens/seg. No es GPT-4 Turbo, pero para tasks específicos — clasificación, resumen corto, extracción de datos — funciona.

El momento de "pará, esto cambia todo"

Después de que funcionó, apagué el WiFi. Escribí una pregunta. La respuesta llegó igual.

Yo vengo de 33 años viendo cómo el cómputo migra. El patrón siempre fue el mismo: el poder empieza centralizado, se democratiza hacia el edge, y en algún punto llega al dispositivo. La Amiga hacía en el cliente lo que antes necesitaba un mainframe. El cyber café donde laburé a los 14 tenía más poder de cómputo que instituciones enteras de diez años antes. Cada generación, el cliente se come un pedazo del servidor.

Lo que acaba de pasar con los LLMs es exactamente ese mismo movimiento, pero en cámara rápida.

Las implicaciones concretas:

Sin billing por inferencia. Cero costo de API. El usuario trae su propia GPU. Si tu app tiene 100.000 usuarios activos haciendo 50 queries por día, con GPT-4 eso son números que duelen. Con inferencia en el cliente, son literalmente cero dólares de inferencia.

Sin latencia de red. El round-trip a un servidor en us-east-1 desde Argentina son 200-300ms antes de que empiece a llegar el primer token. Local: 0ms. Para UX esto es brutal — la diferencia entre "espero que cargue" y "responde al instante".

Sin datos que salen del dispositivo. Para casos de uso con datos sensibles — documentos legales, notas médicas, código propietario — inferencia local cambia el juego. El dato no viaja a ningún lado.

Conectando con lo que escribí sobre sandboxes para agentes de código: parte del problema de darle autonomía a un agente es el costo y la latencia de cada LLM call. Si el modelo corre local, la economía del problema cambia completamente.

Errores y gotchas que me comí

No todo fue bonito. Los problemas reales:

WebGPU no está en todos lados. Firefox lo tiene detrás de un flag. Safari lo agregó en versiones recientes. El fallback a WebAssembly funciona, pero es 3-5x más lento. Necesitás feature detection y manejar el degraded experience.

// Detectar soporte antes de intentar cargar
const checkWebGPU = async (): Promise<boolean> => {
  if (!navigator.gpu) return false;

  try {
    const adapter = await navigator.gpu.requestAdapter();
    return adapter !== null;
  } catch {
    return false;
  }
};

// Elegir device según soporte
const device = (await checkWebGPU()) ? 'webgpu' : 'wasm';
Enter fullscreen mode Exit fullscreen mode

El primer load es un problema de UX real. 1.5GB en la primera visita es mucho. Tuve que agregar una pantalla de "instalación" explícita con progreso claro. Tratarlo como una PWA que se instala, no como una página que carga.

Memoria RAM. El modelo cuantizado necesita ~1-2GB de RAM. En dispositivos con 4GB totales, esto puede freezar el tab. Necesitás setear expectativas y ofrecer fallback a API cloud para dispositivos que no den el ancho.

El modelo es chico — actúa como tal. Gemma 2B no es GPT-4. Para summarization corta, clasificación, y tareas con mucho contexto en el prompt, anda bien. Para razonamiento complejo o generación larga, los resultados son notoriamente peores. Yo calibré mis expectativas después de una hora de pruebas. El truco es diseñar la task para el modelo, no al revés.

Esto me conectó con algo que aprendí optimizando la app de Next.js que bajé de 3 segundos a 300ms: la performance no viene de apretar un botón mágico, viene de entender qué está pasando realmente y diseñar en función de eso.

Context window limitada. El modelo cuantizado que usé tiene 2048 tokens de contexto efectivo. Si mandás un documento largo, lo trunca sin avisarte. Tuve que implementar chunking explícito.

// Chunking básico para no superar el context window
const MAX_TOKENS_APROX = 1500; // margen de seguridad
const CHARS_POR_TOKEN_APROX = 4;
const MAX_CHARS = MAX_TOKENS_APROX * CHARS_POR_TOKEN_APROX;

const truncarContexto = (texto: string): string => {
  if (texto.length <= MAX_CHARS) return texto;
  // Truncamos desde el principio, preservamos el final (suele ser más relevante)
  return '...' + texto.slice(texto.length - MAX_CHARS);
};
Enter fullscreen mode Exit fullscreen mode

Esto también lo sentí cuando estuve trabajando con Claude Code en febrero — el context management es el problema que nadie resuelve del todo bien todavía.

FAQ: Gemma LLM en el browser sin API keys

¿Qué navegadores soportan WebGPU para correr Gemma?
Chrome 113+ y Edge tienen soporte estable. Safari 18+ lo soporta. Firefox lo tiene detrás de dom.webgpu.enabled en about:config, no está en producción todavía. Para producción real hoy, Chrome/Edge son el target seguro. Siempre implementá fallback a WebAssembly para los demás.

¿Cuánto pesa el modelo y cómo manejo la primera descarga?
Gemma 2B cuantizado en 4-bit pesa ~1.4-1.6GB. La primera descarga es real y tarda — en conexiones lentas puede ser 5-10 minutos. La clave es tratarlo como instalación de PWA: pantalla de progreso explícita, explicación de que es una sola vez, y que después el browser lo cachea en Cache Storage. Visits siguientes: carga en segundos.

¿Qué tan rápida es la inferencia comparada con una API en la nube?
Depende mucho del hardware. En una GPU discreta moderna (RTX 3060+): 15-25 tokens/segundo con WebGPU. En hardware integrado (Apple Silicon M1): 8-15 tokens/seg. En CPU via WASM: 1-3 tokens/seg, notoriamente lento. La API de OpenAI/Anthropic entrega 50-100 tokens/seg con mejor calidad. La ventaja local no es velocidad bruta, es latencia cero de red y costo cero.

¿Funciona offline completamente?
Sí, esa es la parte que me cambió el esquema mental. Una vez que el modelo está cacheado, la inferencia corre sin ningún request de red. Lo probé apagando el WiFi. Funciona. Esto abre casos de uso que antes eran imposibles: apps para zonas con conectividad intermitente, herramientas que manejan datos sensibles que no pueden salir del dispositivo, features que funcionan en aviones/subtes/donde sea.

¿Tiene sentido para producción o es un experimento?
Hoy está en algún punto entre experimento avanzado y producción early-adopter. Los casos donde ya tiene sentido: apps con datos sensibles (legal, médico, notas personales), features nice-to-have donde el fallback es simplemente no tenerlas, usuarios tech-savvy con hardware bueno. Los casos donde todavía no escala: experiencia de usuario masivo en mobile con hardware variado, tareas que requieren el nivel de razonamiento de modelos grandes, apps donde 1.5GB de primera descarga rompe el funnel.

¿Qué pasa con móviles?
WebGPU en mobile está en desarrollo pero limitado. Chrome en Android está avanzando, iOS Safari tiene soporte parcial. El problema gordo es RAM — los phones con 4-6GB no tienen margen para cargar 1.5GB de modelo. Gemma 1B (la versión más chica, ~700MB cuantizado) es más viable para mobile. La realidad honesta: mobile-first con inferencia local todavía tiene 1-2 años por delante para ser confiable.

Conclusión: el cómputo siempre migra hacia el edge

Lo que viví con Gemma en el browser es el mismo patrón que vi cuando el cyber café donde laburé empezó a tener más poder que servers de empresas de cinco años antes. El cómputo siempre migra hacia el edge. Siempre.

No estoy diciendo que las APIs de cloud van a desaparecer. GPT-4, Claude, Gemini Pro — para los casos que necesitan el mayor nivel de capacidad, van a seguir siendo la respuesta. Pero hay toda una categoría de features — clasificación, summarización, extracción, asistencia contextual — donde un modelo chico corriendo en el cliente resuelve el problema igual de bien, sin costo de API, sin latencia de red, sin datos que salen del dispositivo.

El cambio más grande para mí no fue técnico. Fue conceptual: dejé de pensar en "LLM en mi app" como sinónimo de "API call a un cloud endpoint". Ahora es una decisión arquitectural real: ¿este modelo va en el servidor, en el edge, o en el cliente?

Y una vez que hacés esa pregunta, no podés dejar de hacerla.

Si ya leíste el post sobre LLMs chicos en Next.js y te quedaste con ganas de ir un paso más allá, este es el paso. Bajate Transformers.js, cargá Gemma, apagá el WiFi, y preguntale algo. La primera vez que responde sin que ningún paquete salga a la red, vas a tener el mismo momento que tuve yo.

Vale la pena.


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)