Construyes una extensión, guardas los ajustes en una variable global y todo funciona. Cierras el popup o esperas un rato y los datos parecen haberse reiniciado.
Es una trampa habitual de MV3. El worker puede terminar al quedar inactivo y el popup se destruye al cerrarse. Las variables viven en la memoria de esos contextos.
Aquí explico por qué desaparece ese estado, cómo conservarlo con chrome.storage y cómo añadir tipos a las lecturas y escrituras.
Esta guía parte de la arquitectura de Manifest V3, donde explico el ciclo de vida del worker.
Por qué desaparecen las variables globales
En MV3, el proceso de segundo plano es un service worker:
- Arranca cuando recibe eventos, como mensajes, alarmas o cambios de pestaña.
- Puede terminar tras unos treinta segundos de inactividad, con excepciones según las APIs usadas.
- Al volver a arrancar, sus variables se inicializan de nuevo.
// background.ts, THIS WILL NOT WORK
let userSettings = { theme: "dark", blockedSites: [] };
chrome.runtime.onMessage.addListener((message) => {
if (message.type === "UPDATE_SETTINGS") {
userSettings = message.payload; // Saved in memory
// 30 seconds later... service worker terminates
// userSettings is gone forever
}
});
Al cerrar y abrir el popup también se crea un nuevo documento con nueva memoria JavaScript. Eso no borra el almacenamiento persistente, pero sí sus variables.
chrome.storage como capa de estado
Esta guía se centra en dos áreas persistentes para extensiones:
El área chrome.storage.local
- Guarda datos en el dispositivo.
- Tiene un límite predeterminado de 10 MB, ampliable con
unlimitedStorage. - Sobrevive a la terminación del worker, reinicios del navegador y actualizaciones.
- No sincroniza esos datos entre dispositivos.
El área chrome.storage.sync
- Puede sincronizar ajustes entre instancias de Chrome con la sincronización habilitada.
- Sus cuotas incluyen 100 KB en total, 8 KB por elemento y 512 elementos.
- Tiene límites de escritura y sincronización que debes contemplar.
- Resulta útil para preferencias pequeñas.
Cómo elegir el área
Usa sync para ajustes que deban seguir al usuario, como el tema o sus preferencias. Usa local para cachés, sesiones, analíticas y datos grandes o propios del dispositivo.
Operaciones básicas
// Write
await chrome.storage.sync.set({
blockedSites: ["reddit.com", "twitter.com"],
challengeLevel: "medium",
});
// Read
const result = await chrome.storage.sync.get(["blockedSites", "challengeLevel"]);
console.log(result.blockedSites); // ["reddit.com", "twitter.com"]
// Read with defaults
const { theme = "light" } = await chrome.storage.sync.get({ theme: "light" });
// Remove
await chrome.storage.sync.remove("blockedSites");
// Clear everything
await chrome.storage.sync.clear();
El problema que aparece después es el tipado: una lectura puede devolver datos que TypeScript no conoce con precisión. Necesitas tipos y, cuando corresponda, validación durante la ejecución.
Crear un wrapper de almacenamiento tipado
Define una vez el esquema de datos y sus valores predeterminados:
// src/storage/schema.ts
export interface SyncStorageSchema {
blockedSites: string[];
challengeLevel: "easy" | "medium" | "hard";
theme: "light" | "dark";
dailyGoalMinutes: number;
enableNotifications: boolean;
}
export interface LocalStorageSchema {
sessionStartTime: number | null;
totalFocusSeconds: number;
lastSyncTimestamp: number;
}
// Defaults, used for initial state and fallback
export const SYNC_DEFAULTS: SyncStorageSchema = {
blockedSites: [],
challengeLevel: "medium",
theme: "light",
dailyGoalMinutes: 120,
enableNotifications: true,
};
export const LOCAL_DEFAULTS: LocalStorageSchema = {
sessionStartTime: null,
totalFocusSeconds: 0,
lastSyncTimestamp: 0,
};
Después crea el wrapper:
// src/storage/typedStorage.ts
import type { SyncStorageSchema, LocalStorageSchema } from "./schema";
import { SYNC_DEFAULTS, LOCAL_DEFAULTS } from "./schema";
type StorageArea = "sync" | "local";
type SchemaFor<A extends StorageArea> = A extends "sync"
? SyncStorageSchema
: LocalStorageSchema;
type DefaultsFor<A extends StorageArea> = A extends "sync"
? typeof SYNC_DEFAULTS
: typeof LOCAL_DEFAULTS;
function createStorage<A extends StorageArea>(
area: A,
defaults: DefaultsFor<A>
) {
const storage = chrome.storage[area];
return {
async get<K extends keyof SchemaFor<A>>(
key: K
): Promise<SchemaFor<A>[K]> {
const result = await storage.get({ [key]: defaults[key as string] });
return result[key as string] as SchemaFor<A>[K];
},
async getAll(): Promise<SchemaFor<A>> {
const result = await storage.get(defaults);
return result as SchemaFor<A>;
},
async set<K extends keyof SchemaFor<A>>(
key: K,
value: SchemaFor<A>[K]
): Promise<void> {
await storage.set({ [key]: value });
},
async setMany(items: Partial<SchemaFor<A>>): Promise<void> {
await storage.set(items);
},
onChange(
callback: (
changes: Partial<{
[K in keyof SchemaFor<A>]: {
oldValue: SchemaFor<A>[K];
newValue: SchemaFor<A>[K];
};
}>
) => void
) {
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName === area) {
callback(changes as any);
}
});
},
};
}
export const syncStorage = createStorage("sync", SYNC_DEFAULTS);
export const localStorage = createStorage("local", LOCAL_DEFAULTS);
Así se usa con inferencia de tipos:
// Any component, popup, background, content script
import { syncStorage, localStorage } from "./storage/typedStorage";
// TypeScript knows this returns string[]
const sites = await syncStorage.get("blockedSites");
// TypeScript enforces value type
await syncStorage.set("challengeLevel", "hard"); // OK
await syncStorage.set("challengeLevel", "extreme"); // Type error
// Listen for changes with typed diffs
syncStorage.onChange((changes) => {
if (changes.theme) {
console.log(`Theme: ${changes.theme.oldValue} → ${changes.theme.newValue}`);
}
});
Responder a los cambios de almacenamiento
chrome.storage.onChanged permite que los contextos autorizados reciban los cambios de datos y actualicen su interfaz:
// popup.ts, update UI when background changes settings
syncStorage.onChange((changes) => {
if (changes.blockedSites) {
renderBlockedSitesList(changes.blockedSites.newValue);
}
});
Para este tipo de sincronización de estado no necesitas enviar además un mensaje manual por cada cambio. Eso reduce la cantidad de mensajes entre componentes.
Cómo usa el almacenamiento ResistGate
ResistGate combina ambas áreas en el diseño descrito aquí:
chrome.storage.sync conserva ajustes pequeños: sitios bloqueados, dificultad de los retos y duración del modo de compromiso. Con la sincronización habilitada, la lista puede seguir al usuario.
chrome.storage.local conserva datos de sesión: inicio del periodo de concentración, tiempo ganado, historial de retos y puntuaciones semanales. Es información más voluminosa y propia del dispositivo.
El patrón importante es leer el estado persistido cuando el worker despierta. En este ejemplo, localStorage.get("sessionStartTime") se refiere al wrapper de chrome.storage.local definido antes, no a window.localStorage. Una variable global no sobreviviría a la terminación.
Errores habituales
Tratar el almacenamiento como estado síncrono. Las lecturas y escrituras son asíncronas. En una ruta frecuente, conserva una caché en memoria y actualízala con onChanged.
Omitir valores predeterminados. Si una clave no existe, get("key") devuelve un objeto sin esa propiedad. Usa get({ key: defaultValue }) cuando necesites un valor de respaldo.
Superar las cuotas de sync. No lo uses para grandes historiales. local suele ser más adecuado para ese volumen.
Guardar funciones o instancias esperando recuperarlas intactas. El almacenamiento serializa valores. Usa objetos y primitivas; convierte explícitamente fechas y otras estructuras a una representación que puedas reconstruir.
Comparación con IndexedDB y las APIs web
chrome.storage: disponible en el popup, el worker y los scripts autorizados. Su API es asíncrona y las áreas persistentes sobreviven a los reinicios. Es mi primera opción para estado compartido de la extensión.
IndexedDB: disponible en service workers. Sirve para datos estructurados de mayor volumen o necesidades que una API de clave y valor no resuelve bien, a cambio de más complejidad.
sessionStorage y localStorage de la web: existen en contextos con documento, pero no en el worker. localStorage puede sobrevivir al cierre del popup, aunque no es una capa compartida con el worker. En un script de contenido pertenece a la página anfitriona. Consulta la guía de almacenamiento de Chrome.
Cómo continuar
Con el estado persistido puedes cerrar el popup o reiniciar el worker sin perder los datos que guardaste:
- Conecta ese almacenamiento a la interfaz con la guía de React y TypeScript.
- Revisa el acceso en la guía de permisos.
- Usa cachés y estado de conversación al añadir una función con Claude.
Esta nota forma parte de la serie Extensiones de Chrome: de cero a producto, donde explico lo que aprendí construyendo ResistGate y Amethyst, desde el primer popup hasta la publicación.
Un pequeño voto de confianza
¿Valió la pena leer esta nota?