How to Persist State in a Chrome Extension (Without Losing It)
Service workers terminate; your global state disappears with them.
How to Persist State in a Chrome Extension (Without Losing It)
You write a Chrome extension. You store user settings in a global variable. It works perfectly — until you close the popup or wait 30 seconds. Then everything resets.
This is the most common MV3 trap. Service workers terminate when idle, and popups are destroyed on close. Global variables live in memory, and memory doesn't survive either event.
This post covers why state disappears, how chrome.storage fixes it, and how to build typed wrappers that make storage painless.
Context: This builds on the architecture from How Chrome Extensions Actually Work in 2026. If you haven't read that, the service worker termination behavior won't make sense.
Why Global Variables Die
In Manifest V3, the background script runs as a service worker. Unlike MV2's persistent background page, a service worker:
- Starts when an event fires (message received, alarm triggered, tab updated)
- Terminates after ~30 seconds of inactivity
- Restarts fresh on next event — all variables reset to their initial values
// 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
}
});
Popups have the same problem. Every time the popup closes and reopens, it's a fresh HTML page with fresh JavaScript. Nothing carries over.
chrome.storage — The Real State Layer
Chrome provides two storage areas designed for extensions:
chrome.storage.local
- Stored on the local machine
- Default limit: 10MB (can request
unlimitedStoragepermission) - Survives service worker termination, browser restarts, extension updates
- Not synced across devices
chrome.storage.sync
- Synced across all Chrome instances where user is signed in
- Limit: 100KB total, 8KB per item, 512 items max
- Slower writes (sync overhead)
- Perfect for user preferences and settings
When to Use Which
Use sync for settings the user configures (theme, blocked sites, preferences). Use local for everything else — cached data, session state, analytics, anything large.
Basic Usage
// 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();
The API is simple. The problem is type safety — get returns Record<string, any> by default. Every read requires type assertions or runtime validation.
Building a Typed Storage Wrapper
Define your storage schema once, then get full type inference on every read and write:
// 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,
};
Now the typed 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);
Usage is now clean and fully typed:
// 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}`);
}
});
Reacting to Storage Changes
chrome.storage.onChanged fires in every component when storage changes — popup, service worker, and content scripts all get notified. This is powerful for keeping UI in sync:
// popup.ts — update UI when background changes settings
syncStorage.onChange((changes) => {
if (changes.blockedSites) {
renderBlockedSitesList(changes.blockedSites.newValue);
}
});
No message passing needed for state sync. Storage changes broadcast automatically. This reduces the amount of manual message passing you need.
How ResistGate Uses Storage
ResistGate uses both storage areas:
chrome.storage.sync stores user settings — blocked sites, challenge difficulty, commitment mode duration. These sync across devices so your blocked site list follows you.
chrome.storage.local stores session data — current focus session start time, total earned screen time, challenge completion history, weekly discipline scores. This data is large and device-specific, so sync doesn't make sense.
The critical pattern: when the service worker wakes up to intercept a navigation, it reads localStorage.get("sessionStartTime") to check if a focus session is active. If it stored that in a global variable, it would be gone after every idle termination.
Common Mistakes
Treating storage like synchronous state. Every get and set is async. If you read storage in a hot path (like every keystroke), cache it in a local variable and refresh from storage on onChanged.
Not setting defaults. chrome.storage.sync.get("key") returns {} if the key doesn't exist — not undefined, not null. Always pass a default: get({ key: defaultValue }).
Exceeding sync limits. 8KB per item, 100KB total for sync. If you're storing large arrays (like browsing history), use local instead. The 10MB local limit is generous.
Storing functions or class instances. Storage serializes to JSON. Functions, Map, Set, Date objects — all lost on round-trip. Store plain objects and primitives.
Storage vs IndexedDB vs SessionStorage
Quick comparison for extension contexts:
chrome.storage — Works everywhere (popup, background, content script). Survives restarts. Async API. This is what you should use 95% of the time.
IndexedDB — Available in service workers. Good for large structured data (>10MB). More complex API. Use only when chrome.storage.local isn't enough.
sessionStorage / localStorage (web APIs) — Available in popup and options page only. NOT available in service workers. Do not use these in extensions — they don't share state across components and they don't survive popup close.
What's Next
With typed storage, your extension state survives service worker termination and popup close/reopen cycles. Next steps:
- Build the UI — Building a Chrome Extension Popup with React and TypeScript shows how to wire storage into React state.
- Lock down access — Chrome Extension Permissions covers how permission choices affect what storage APIs and features you can use.
- Add AI features — Building an AI-Powered Chrome Extension with Claude uses storage to cache API responses and manage conversation state.
This is part of the Chrome Extensions from Zero to Product series, where I walk through everything I learned building ResistGate and Amethyst — from first popup to published product.
Building in public
Follow the journey
as I build AI tools, products, and a serious founder life.
No spam. Unsubscribe anytime.