§ NOTA TÉCNICA · ARQUITECTURA CLIENTE

Cross-pollination entre apps sin backend con localStorage + IndexedDB

El aGo bridge permite que aGo Palette mande una paleta a aGo CSS Gradient, que aGo Canvas exporte un layer a aGo Carousels, o que aGo Squeezer mande imágenes comprimidas a aGo Stickers. Todo sin servidor, sin cuenta de usuario, sin enviar datos a ningún lado. Esta nota documenta el diseño mixto localStorage más IndexedDB con threshold de 500 KB, schema versionado, TTL de 24 horas y las limitaciones honestas del enfoque.

El contexto: tools que dialogan sin servidor

Una decisión temprana de tools.ago.cl es la composabilidad. Una tool no es una isla cerrada: el usuario debería poder armar una paleta en aGo Palette, hacer click en "enviar a Gradient" y aparecer en aGo CSS Gradient con la paleta cargada. Sin servidor. Sin login. Solo el navegador.

Esta cross-pollination es la propuesta de valor de la suite. Cada tool individualmente compite con productos puntuales (Squoosh para compresión, Coolors para paletas, etc.). La diferencia está en que aquí pasan los datos entre ellas sin fricción y sin comprometer privacidad.

El problema técnico es claro: ¿cómo persistes estado entre páginas distintas del mismo origen, posiblemente entre sesiones, sin servidor? Las opciones disponibles en el navegador son varias, cada una con su perfil de capacidad y limitación.

Opciones evaluadas

Opción A · localStorage solo

Persistente, síncrono, fácil API.

Pros: simple, soporte universal, API directa.

Contras: límite práctico de 5 MB total por origen, síncrono (bloquea hilo principal), no apto para blobs grandes.

Opción B · IndexedDB solo

Async, capacidad alta, soporta blobs binarios.

Pros: sin límite práctico chico, no bloquea, ideal para datos grandes.

Contras: API verbosa, asincrónico todo (refactor de código sync), Safari modo privado restringe.

Opción C · BroadcastChannel

Mensajería entre tabs del mismo origen.

Pros: real-time entre tabs, API simple.

Contras: no persiste; si la tab origen muere, el dato se pierde. No sirve para flujo "copio acá, abro mañana".

Opción D · Shared Worker

Worker compartido entre tabs con estado persistente en memoria.

Pros: mental model interesante para multi-tab.

Contras: Safari soporte irregular, no persiste entre sesiones, sobre-ingeniería para nuestro caso.

Opción E · Mixto localStorage + IndexedDB

localStorage para payloads chicos, IndexedDB para grandes, threshold automático.

Pros: aprovecha simplicidad sync para casos chicos, capacidad async para grandes, soporte universal.

Contras: dos APIs que mantener, capa de abstracción propia.

La decisión: mixto con threshold 500 KB

Elegimos la opción E. El razonamiento fue empírico. La mayoría de los envíos entre tools son chicos: un color HSL son 30 bytes, una paleta de 8 colores con metadata son unos pocos KB, una receta de gradiente CSS son menos de 5 KB. Para esos casos localStorage es perfecto: sincrónico, simple, sin promesas que awaitar.

Hay un puñado de casos donde el envío es grande. Una imagen base64 desde Squeezer a Stickers puede ser 800 KB. Un canvas exportado desde Canvas a Carousels con varias capas puede pasar 2 MB. Para esos casos localStorage no alcanza y IndexedDB es la ruta correcta.

Definimos el threshold en 500 KB tras medir el costo real de stringificar y serializar en distintos escenarios. Bajo 500 KB la API sync de localStorage es indistinguible en UX. Sobre 500 KB el bloqueo del hilo principal se nota en mobile, especialmente en equipos antiguos. Esa fue la frontera práctica.

Implementación: la capa bridge

El bridge expone dos funciones: bridgeSend y bridgeReceive. La tool origen llama bridgeSend y obtiene un id corto. Luego redirige al usuario a la tool destino con ese id en el query string. La tool destino llama bridgeReceive(id) al cargar y obtiene el payload.

/**
 * aGo bridge: capa para enviar estado entre tools sin servidor.
 * Usa localStorage para payloads chicos (<500 KB), IndexedDB para grandes.
 */

const SCHEMA_VERSION = 1;
const TTL_MS = 24 * 60 * 60 * 1000; // 24 horas.
const THRESHOLD_BYTES = 500 * 1024;
const KEY_PREFIX = 'ago_bridge_';
const IDB_DB = 'ago_bridge';
const IDB_STORE = 'payloads';

interface BridgeRecord<T> {
  schemaVersion: number;
  source: string;
  type: string;
  payload: T;
  createdAt: number;
}

function generarId(): string {
  // Id corto, suficientemente único para 24 h dentro del mismo navegador.
  return Math.random().toString(36).slice(2, 10);
}

function tamanoBytes(obj: unknown): number {
  return new TextEncoder().encode(JSON.stringify(obj)).length;
}

async function openIdb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(IDB_DB, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(IDB_STORE)) {
        db.createObjectStore(IDB_STORE, { keyPath: 'id' });
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

/**
 * Envía un payload a la capa bridge.
 * Decide automáticamente entre localStorage e IndexedDB según el tamaño.
 */
export async function bridgeSend<T>(args: {
  source: string;
  type: string;
  payload: T;
}): Promise<string> {
  const id = generarId();
  const record: BridgeRecord<T> = {
    schemaVersion: SCHEMA_VERSION,
    source: args.source,
    type: args.type,
    payload: args.payload,
    createdAt: Date.now()
  };

  const bytes = tamanoBytes(record);

  if (bytes < THRESHOLD_BYTES) {
    try {
      localStorage.setItem(KEY_PREFIX + id, JSON.stringify(record));
      return id;
    } catch (err) {
      // QuotaExceededError o modo privado: caer a IndexedDB.
      console.warn('localStorage falló, intentando IndexedDB', err);
    }
  }

  const db = await openIdb();
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction(IDB_STORE, 'readwrite');
    tx.objectStore(IDB_STORE).put({ id, ...record });
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });

  return id;
}

/**
 * Lee un payload desde la capa bridge. Busca en ambos storages.
 * Retorna null si no existe, está expirado o el schema es incompatible.
 */
export async function bridgeReceive<T>(
  id: string
): Promise<BridgeRecord<T> | null> {
  // Intentar localStorage primero (sync, rápido).
  const fromLs = localStorage.getItem(KEY_PREFIX + id);
  if (fromLs) {
    try {
      const parsed = JSON.parse(fromLs) as BridgeRecord<T>;
      if (validar(parsed)) return parsed;
    } catch {
      localStorage.removeItem(KEY_PREFIX + id);
    }
  }

  // Buscar en IndexedDB.
  try {
    const db = await openIdb();
    const record = await new Promise<BridgeRecord<T> | null>((resolve) => {
      const tx = db.transaction(IDB_STORE, 'readonly');
      const req = tx.objectStore(IDB_STORE).get(id);
      req.onsuccess = () => resolve(req.result ?? null);
      req.onerror = () => resolve(null);
    });

    if (record && validar(record)) return record;
  } catch {
    // IndexedDB no disponible (modo privado, quota, etc.).
  }

  return null;
}

function validar<T>(record: BridgeRecord<T>): boolean {
  if (record.schemaVersion !== SCHEMA_VERSION) {
    console.warn('Schema version mismatch, descartando record bridge.');
    return false;
  }
  if (Date.now() - record.createdAt > TTL_MS) {
    return false;
  }
  return true;
}

/**
 * Limpia records expirados de ambos storages.
 * Recomendado llamar on-boot del shell, una vez por sesión.
 */
export async function bridgeCleanup(): Promise<void> {
  const ahora = Date.now();

  // Limpia localStorage.
  const keys = Object.keys(localStorage).filter((k) => k.startsWith(KEY_PREFIX));
  for (const key of keys) {
    try {
      const record = JSON.parse(localStorage.getItem(key) ?? 'null');
      if (!record || ahora - record.createdAt > TTL_MS) {
        localStorage.removeItem(key);
      }
    } catch {
      localStorage.removeItem(key);
    }
  }

  // Limpia IndexedDB.
  try {
    const db = await openIdb();
    const tx = db.transaction(IDB_STORE, 'readwrite');
    const store = tx.objectStore(IDB_STORE);
    const req = store.openCursor();
    req.onsuccess = (e) => {
      const cursor = (e.target as IDBRequest<IDBCursorWithValue | null>).result;
      if (cursor) {
        if (ahora - cursor.value.createdAt > TTL_MS) {
          cursor.delete();
        }
        cursor.continue();
      }
    };
  } catch {
    // Sin IndexedDB nada que limpiar.
  }
}

Cinco detalles del código que importan en la práctica.

Uno. El threshold se evalúa con el tamaño real serializado, no con un estimado por tipo de payload. Esto evita sorpresas con paletas que parecían chicas pero tenían thumbnails embebidos.

Dos. El catch del setItem captura QuotaExceededError sin propagar. Cuando localStorage está lleno (o estamos en modo privado de Safari), el bridge cae automáticamente a IndexedDB. El usuario nunca ve un error.

Tres. bridgeReceive intenta localStorage primero porque es sync y rápido. Solo si no encuentra ahí va a IndexedDB. La asimetría es deliberada para minimizar latencia en el camino común.

Cuatro. schemaVersion permite que cambiemos el formato del record en el futuro sin romper datos viejos en navegadores de usuarios que no recargaron en días. La política es "version mismatch, descarta". Si necesitas migrar en vez de descartar, se agrega lógica de migración en validar().

Cinco. bridgeCleanup se llama una vez al boot del shell. Eso previene que records expirados se acumulen indefinidamente. 24 horas es el TTL elegido por encajar con el patrón típico de uso "abro Palette, mando a Gradient, sigo trabajando el resto del día".

Cómo lo usa cada tool

Una tool origen tiene un botón "Enviar a [destino]". El handler llama bridgeSend con el estado actual y redirige.

async function handleSendToGradient(paleta: Paleta) {
  const id = await bridgeSend({
    source: 'palette',
    type: 'paleta-completa',
    payload: paleta
  });

  // Redirección con id en query string.
  window.location.href = `/css-gradient/?bridge=${id}`;
}

La tool destino, en su isla React, lee el id del query string y carga el payload al montarse.

import { useEffect, useState } from 'react';
import { bridgeReceive } from '@/lib/bridge';

export function GradientEditor() {
  const [paletaInicial, setPaletaInicial] = useState<Paleta | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    const id = params.get('bridge');
    if (!id) return;

    bridgeReceive<Paleta>(id).then((record) => {
      if (record && record.type === 'paleta-completa') {
        setPaletaInicial(record.payload);
      } else {
        setError('El enlace expiró o el formato cambió. Empieza desde cero.');
      }
    });
  }, []);

  // ... resto del editor.
}

El patrón es deliberadamente simple. Sin observables, sin store global, sin suscripciones. La tool destino lee una vez al montar y desde ahí maneja su propio estado. Si el usuario refresca, el id ya fue consumido y la tool sigue con el último estado en memoria (o se reinicia, según política de cada tool).

Limitaciones honestas que descubrimos

1. IndexedDB es async, todo el código consumidor también

Refactorizar tools que asumían lectura sync de localStorage para soportar IndexedDB async requirió tocar varios componentes. La regla práctica fue: si una tool puede recibir payloads grandes, el editor entero usa initialState undefined hasta que el bridge resuelve. Mientras tanto muestra un skeleton.

2. Safari modo privado restringe ambos storages

En Safari modo privado, localStorage tiene quota de unos pocos KB y se borra al cerrar. IndexedDB a veces no permite escrituras (depende de versión iOS). El bridge detecta el fallo y degrada graceful: el botón "Enviar a [destino]" se desactiva con tooltip explicativo. El usuario puede usar cada tool individualmente sin problema.

3. No es sync entre dispositivos

El bridge es local al navegador. Si el usuario abre Palette en el portátil y luego quiere recibir la paleta en el teléfono, no funciona. Para sync cross-device hay que ir a un backend (Firebase, Supabase, propio). Esa es una decisión consciente de producto: el costo de mantener cuenta de usuario y servidor no compensa el beneficio para nuestra audiencia objetivo.

4. Migraciones de schema requieren plan

La política actual de "version mismatch, descarta" funciona porque el TTL es 24 horas y los cambios de schema son raros. Si en el futuro necesitamos persistencia más larga o cambios frecuentes, hay que escribir migraciones reales. Eso suma código y testing.

5. Quota total es compartida con todas las tools

El origen tools.ago.cl tiene una sola quota de storage para todas las tools. Si una tool guarda mucho (aGo Canvas con autosaves grandes), puede comerse el espacio que otra tool necesita. Mitigación: cada tool tiene su propio namespace y reglas de retención. El bridge usa su propio prefijo y limpia su sección.

6. TextEncoder no es universal en navegadores muy viejos

El cálculo de tamaño con TextEncoder funciona desde IE Edge en adelante. Para compatibilidad más profunda (que no perseguimos), habría que usar blob.size con un Blob temporal, o estimar por longitud de string.

Cuándo NO usar este patrón

  • Necesitas sync real entre dispositivos. Si el caso de uso es "armé la paleta en el escritorio y la quiero en el teléfono", esto no aplica. Necesitas backend con cuenta de usuario y sync server-driven.
  • Colaboración multi-usuario en tiempo real. Si dos personas editan al mismo tiempo, el bridge local no resuelve nada. Necesitas CRDT o backend con merge.
  • Compliance que exige logs server-side. Si tu sector regulado requiere que cada operación quede registrada en un servidor auditable, el bridge local no provee eso.
  • Payloads enormes consistentemente. Si todos tus envíos son de decenas de MB (video, datasets grandes), IndexedDB rinde pero te acercas a límites de quota. Considera File System Access API o backend.

Cuotas reales por navegador

Navegador localStorage IndexedDB
Chrome / Edge desktop ~10 MB por origen Hasta 60% del disco libre del usuario, segmentado por origen.
Firefox desktop ~10 MB por origen Hasta 50% del disco libre, segmentado.
Safari desktop ~5 MB por origen Hasta 1 GB inicial, puede pedirse más vía permiso.
Safari iOS modo normal ~5 MB ~1 GB pero con eviction temprano si el dispositivo necesita espacio.
Safari iOS modo privado Pocos KB, se borra al cerrar Puede fallar al escribir.
Chrome Android ~10 MB Cuota dinámica según espacio disponible.

Estos números son aproximados al 2026-05-24. Verifica con navigator.storage.estimate() en runtime si tu caso depende de cifras exactas. La capa bridge no depende de números exactos; depende de manejar QuotaExceededError graceful.

Lecciones que nos llevamos

La principal lección fue resistir la tentación de sobrediseñar. La primera versión del bridge tenía suscripciones tipo observable, eventos cross-tab con BroadcastChannel, retry automático con backoff. Era impresionante en código y nadie lo usaba bien. La versión actual es deliberadamente boba: una función para mandar, otra para recibir, nada más. El producto mejoró cuando el bridge se hizo invisible.

La segunda lección fue presupuestar el TTL desde el día uno. Sin TTL, los storages se llenan con records muertos que el usuario olvidó. Con TTL de 24 horas, la limpieza es natural. La frontera exacta (24 h vs 48 h vs 7 d) es discutible; lo que no es discutible es que tenga TTL.

La tercera lección fue tratar el envío como un enlace, no como un mensaje. Generar un id corto y ponerlo en el query string del destino es mucho más simple que cualquier sistema de mensajería. El estado vive en el storage, el query string es solo el puntero. Si el usuario comparte la URL no comparte el dato (que vive solo en su navegador), lo cual además es la propiedad de privacidad que queríamos.

Recursos consultados

  • MDN docs sobre localStorage e IndexedDB.
  • web.dev guía de storage quotas y eviction.
  • Pruebas internas aGo lab con Chrome desktop, Safari iOS, Firefox Android.
  • ADR 0006 de tools.ago.cl con la decisión documentada formalmente.

¿Quieres probar el bridge?

Abre aGo Palette, arma una paleta, haz click en "Enviar a Gradient" y verás cómo aparece en la otra tool sin pasar por servidor.

Abrir aGo Palette

¿Necesitas sincronizar estado sin backend?

aGo lab construye software cliente puro que respeta la privacidad de los usuarios. Si tu caso necesita un patrón similar, conversemos.

Conversemos

§ CÓMO CITAR ESTE ARTÍCULO

aGo lab. (2026). "Bridge cross-pollination con IndexedDB local". tools.ago.cl/notas/bridge-cross-pollination-indexeddb. Recuperado el 2026-05-25.

Esta nota es contenido propio de aGo lab y se publica bajo política de atribución requerida. Si la citas o referencias, incluye el link a la URL original.

§ aGo lab estudio

¿Quieres que aGo lab implemente esto para ti?

Si esta decisión técnica te resuelve un problema y necesitas aplicarla a un sistema propio, conversemos.

§ COMMAND

↑↓ navega · Enter abre · Esc cierra