State Management Patterns for Chrome Extensions — Developer Guide
21 min readState Management Patterns for Chrome Extensions
Overview
State management in Chrome extensions presents unique challenges. Your extension runs across multiple isolated contexts—background service workers, content scripts, popups, options pages, and side panels—each with its own memory space and lifecycle. A popup closing destroys its in-memory state. A service worker can terminate and restart at any time. Nothing is shared by default.
This tutorial covers the patterns and APIs you need to build reliable, reactive state management in your extension.
Prerequisites
- Read
docs/tutorials/storage-quickstart.mdfirst npm install @theluckystrike/webext-storagenpm install @theluckystrike/webext-reactive-store
1. Understanding chrome.storage API {#1-understanding-chromestorage-api}
The chrome.storage API is the backbone of extension state management. It provides three storage areas with different characteristics:
1.1 Storage Areas Comparison {#11-storage-areas-comparison}
| Area | Capacity | Sync Support | Persistence |
|---|---|---|---|
local |
10MB per extension | No | Until cleared |
sync |
100KB total, 8KB per item | Yes, across devices | Until cleared |
session |
1MB per extension | No | Until browser closes |
1.2 Using chrome.storage.local {#12-using-chromestoragelocal}
Local storage is best for large, device-specific data:
// background.js - Save large dataset
const cache = { pages: [], timestamp: Date.now() };
// Store with explicit key
await chrome.storage.local.set({ pageCache: cache });
// Retrieve
const result = await chrome.storage.local.get('pageCache');
console.log(result.pageCache);
// Use null to get ALL keys
const allData = await chrome.storage.local.get(null);
// allData: { key1: value1, key2: value2, ... }
1.3 Using chrome.storage.sync {#13-using-chromestoragesync}
Sync storage automatically syncs across devices when the user signs into Chrome:
// Save user preferences - syncs across devices
await chrome.storage.sync.set({
theme: 'dark',
fontSize: 16,
enabled: true
});
// Retrieve with defaults
const { theme = 'light', fontSize = 14 } = await chrome.storage.sync.get(
['theme', 'fontSize']
);
Important quota limits for sync storage:
- Total: 100KB across all keys
- Per-item: 8KB maximum per key
- Exceeding limits throws an error
1.4 Using chrome.storage.session {#14-using-chromestoragesession}
Session storage persists only until the browser closes—useful for temporary data:
// Store temporary authentication token
await chrome.storage.session.set({ authToken: 'temp-token-123' });
// Retrieve
const { authToken } = await chrome.storage.session.get('authToken');
// Session storage doesn't persist across browser restarts
2. Reactive State with webext-reactive-store {#2-reactive-state-with-webext-reactive-store}
The @theluckystrike/webext-reactive-store package provides a reactive state management solution that automatically propagates changes across all extension contexts.
2.1 Installation and Setup {#21-installation-and-setup}
npm install @theluckystrike/webext-reactive-store
2.2 Creating a Reactive Store {#22-creating-a-reactive-store}
// lib/store.ts
import { createReactiveStore } from '@theluckystrike/webext-reactive-store';
interface AppState {
user: { id: string; name: string } | null;
theme: 'light' | 'dark';
items: string[];
lastUpdated: number;
}
const initialState: AppState = {
user: null,
theme: 'light',
items: [],
lastUpdated: Date.now(),
};
export const store = createReactiveStore(initialState, {
storageArea: 'local', // or 'sync'
name: 'appState', // storage key name
});
2.3 Reading and Writing State {#23-reading-and-writing-state}
// Read current state
const state = store.getState();
// { user: null, theme: 'light', items: [], lastUpdated: 1700000000000 }
// Update state (immutable)
store.setState({ theme: 'dark' });
// or with a function
store.setState(prev => ({
...prev,
items: [...prev.items, 'new-item'],
lastUpdated: Date.now()
}));
2.4 Subscribing to Changes {#24-subscribing-to-changes}
The reactive store automatically handles cross-context updates:
// In popup.js - Subscribe to theme changes
const unsubscribe = store.subscribe((state) => {
document.body.className = state.theme;
console.log('State updated:', state);
});
// Unsubscribe when done
unsubscribe();
// In content script - React to any state change
store.subscribe((state) => {
if (state.theme === 'dark') {
document.body.classList.add('dark-mode');
} else {
document.body.classList.remove('dark-mode');
}
});
2.5 Using Middleware {#25-using-middleware}
Add middleware for logging, persistence, or custom behavior:
import { createReactiveStore, createLoggerMiddleware } from '@theluckystrike/webext-reactive-store';
// Create middleware
const logger = createLoggerMiddleware({ prefix: '[MyExt]' });
export const store = createReactiveStore(initialState, {
storageArea: 'local',
name: 'appState',
middleware: [logger],
});
// Now all state changes are logged
store.setState({ theme: 'dark' });
// Logs: [MyExt] State changed: { theme: 'light' } → { theme: 'dark' }
3. Sharing State Between Extension Contexts {#3-sharing-state-between-extension-contexts}
Chrome extensions have multiple isolated contexts. Here’s how to share state between them:
3.1 The Storage-Backed Pattern {#31-the-storage-backed-pattern}
Use chrome.storage as the single source of truth with change listeners:
// lib/shared-state.ts
type SharedState = {
enabled: boolean;
count: number;
user: string | null;
};
const STORAGE_KEY = 'sharedState';
export class SharedStateManager {
private listeners: Set<(state: SharedState) => void> = new Set();
private cache: SharedState | null = null;
constructor() {
// Listen to changes from ANY context
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'local' && changes[STORAGE_KEY]) {
this.cache = changes[STORAGE_KEY].newValue;
this.notifyListeners();
}
});
}
async get(): Promise<SharedState> {
if (this.cache) return this.cache;
const result = await chrome.storage.local.get(STORAGE_KEY);
this.cache = result[STORAGE_KEY] || {
enabled: false,
count: 0,
user: null
};
return this.cache;
}
async update(partial: Partial<SharedState>): Promise<SharedState> {
const current = await this.get();
const next = { ...current, ...partial };
await chrome.storage.local.set({ [STORAGE_KEY]: next });
return next;
}
subscribe(listener: (state: SharedState) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners() {
this.listeners.forEach(fn => fn(this.cache!));
}
}
export const sharedState = new SharedStateManager();
3.2 Using the Pattern in Different Contexts {#32-using-the-pattern-in-different-contexts}
In the background service worker:
// background.js
import { sharedState } from './lib/shared-state.js';
chrome.runtime.onInstalled.addListener(() => {
sharedState.update({ enabled: true, count: 0 });
});
// Listen for messages from popup/content
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'INCREMENT') {
sharedState.update({ count: await sharedState.get().then(s => s.count + 1) });
}
});
In the popup:
// popup.js
import { sharedState } from './lib/shared-state.js';
document.getElementById('count')?.addEventListener('click', async () => {
const state = await sharedState.get();
await sharedState.update({ count: state.count + 1 });
});
// React to changes from other contexts
sharedState.subscribe((state) => {
document.getElementById('display').textContent = `Count: ${state.count}`;
});
In content scripts:
// content.js
import { sharedState } from './lib/shared-state.js';
// React when popup or background changes settings
sharedState.subscribe((state) => {
if (state.enabled) {
initFeature();
} else {
disableFeature();
}
});
3.3 Direct Storage Access with Typed Wrapper {#33-direct-storage-access-with-typed-wrapper}
Using @theluckystrike/webext-storage for type-safe storage:
// lib/storage.ts
import { defineSchema, createStorage } from '@theluckystrike/webext-storage';
const schema = defineSchema({
theme: 'light' as 'light' | 'dark',
user: null as { id: string; name: string } | null,
preferences: {} as { notifications: boolean; autoSave: boolean },
});
export const storage = createStorage({ schema, area: 'local' });
// Usage - fully typed
const theme = await storage.get('theme'); // Type: 'light' | 'dark'
await storage.set('theme', 'dark'); // Type-safe
const { theme, user } = await storage.getMany(['theme', 'user']);
4. Persistence Strategies {#4-persistence-strategies}
4.1 Automatic Persistence with webext-reactive-store {#41-automatic-persistence-with-webext-reactive-store}
The reactive store automatically persists to chrome.storage:
import { createReactiveStore } from '@theluckystrike/webext-reactive-store';
const store = createReactiveStore(
{ count: 0 },
{
storageArea: 'local',
name: 'counter',
autoPersist: true, // Automatically save on every change
}
);
// State changes are automatically persisted
store.setState({ count: 1 });
// → Saved to chrome.storage.local.counter
4.2 Manual Persistence Pattern {#42-manual-persistence-pattern}
For more control, implement manual persistence:
// lib/persistent-store.ts
class PersistentStore<T> {
private cache: T | null = null;
constructor(
private key: string,
private defaults: T
) {}
async init(): Promise<T> {
const result = await chrome.storage.local.get(this.key);
this.cache = { ...this.defaults, ...result[this.key] };
return this.cache;
}
async save(state: T): Promise<void> {
this.cache = state;
await chrome.storage.local.set({ [this.key]: state });
}
get(): T {
if (!this.cache) throw new Error('Store not initialized');
return this.cache;
}
}
// Usage
const settingsStore = new PersistentStore('settings', {
theme: 'light',
enabled: true,
});
await settingsStore.init();
settingsStore.save({ theme: 'dark', enabled: false });
4.3 Lazy Initialization Pattern {#43-lazy-initialization-pattern}
Defer expensive initialization until needed:
// lib/lazy-store.ts
class LazyStore<T> {
private promise: Promise<T> | null = null;
constructor(
private key: string,
private defaults: T,
private initFn: () => Promise<T>
) {}
async get(): Promise<T> {
if (!this.promise) {
this.promise = this.initialize();
}
return this.promise;
}
private async initialize(): Promise<T> {
const stored = await chrome.storage.local.get(this.key);
if (stored[this.key]) {
return { ...this.defaults, ...stored[this.key] };
}
// First run - run initialization logic
const initialized = await this.initFn();
await chrome.storage.local.set({ [this.key]: initialized });
return initialized;
}
}
5. Sync vs Local Storage Tradeoffs {#5-sync-vs-local-storage-tradeoffs}
5.1 When to Use storage.sync {#51-when-to-use-storagesync}
Best for:
- User preferences that should follow across devices
- Small configuration items (<8KB each)
- Settings users expect to be consistent everywhere
// Sync storage - user preferences
await chrome.storage.sync.set({
theme: 'dark',
fontSize: 14,
keyboardShortcuts: { toggle: 'Ctrl+Shift+T' }
});
Quota considerations:
- 100KB total limit
- 8KB per individual key
- Large arrays/objects will fail
5.2 When to Use storage.local {#52-when-to-use-storagelocal}
Best for:
- Large data sets (up to 10MB)
- Device-specific settings
- Cached data that can be regenerated
- Sensitive data that shouldn’t sync
// Local storage - large cache data
await chrome.storage.local.set({
pageCache: { /* large cached pages */ },
lastFetchedUrls: [ /* many URLs */ ],
analyticsBuffer: [ /* analytics events */ ]
});
5.3 Hybrid Approach {#53-hybrid-approach}
Combine both for optimal results:
// lib/hybrid-storage.ts
import { defineSchema, createStorage } from '@theluckystrike/webext-storage';
// Small, syncable settings
const syncSchema = defineSchema({
theme: 'light' as 'light' | 'dark',
enabled: true,
});
// Large, local-only data
const localSchema = defineSchema({
cache: {} as Record<string, any>,
history: [] as string[],
});
export const syncStorage = createStorage({ schema: syncSchema, area: 'sync' });
export const localStorage = createStorage({ schema: localSchema, area: 'local' });
// Now you can use both optimally
await syncStorage.set('theme', 'dark'); // Syncs to other devices
await localStorage.set('cache', largeData); // Stays local, larger capacity
6. Migration Patterns {#6-migration-patterns}
When your data schema changes, you need migration strategies.
6.1 Schema Version Migration {#61-schema-version-migration}
Track schema versions and migrate on update:
// background.js
const SCHEMA_VERSION = 2;
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === 'update') {
await migrateFromPreviousVersion(details.previousVersion);
}
});
async function migrateFromPreviousVersion(previousVersion: string): Promise<void> {
const { schemaVersion } = await chrome.storage.local.get('schemaVersion');
if (!schemaVersion || schemaVersion < 1) {
// Migrate from v0 to v1: flatten nested settings
const oldData = await chrome.storage.local.get('preferences.settings');
if (oldData['preferences.settings']) {
await chrome.storage.local.set({
theme: oldData['preferences.settings'].theme,
fontSize: oldData['preferences.settings'].fontSize,
});
await chrome.storage.local.remove('preferences.settings');
}
}
if (schemaVersion < 2) {
// Migrate from v1 to v2: rename keys
const oldData = await chrome.storage.local.get('theme');
if (oldData.theme !== undefined) {
await chrome.storage.local.set({
colorTheme: oldData.theme
});
await chrome.storage.local.remove('theme');
}
}
// Update schema version
await chrome.storage.local.set({ schemaVersion: SCHEMA_VERSION });
}
6.2 Local to Sync Migration {#62-local-to-sync-migration}
Move data between storage areas with quota handling:
// lib/migrate-local-to-sync.ts
async function migrateToSync(): Promise<void> {
// Get all local data
const localData = await chrome.storage.local.get(null);
// Validate against sync quotas
const syncData: Record<string, any> = {};
for (const [key, value] of Object.entries(localData)) {
const size = JSON.stringify(value).length;
// Skip items too large for sync
if (size > 8192) {
console.warn(`Skipping "${key}" - exceeds 8KB limit`);
continue;
}
syncData[key] = value;
}
// Check total size
const totalSize = JSON.stringify(syncData).length;
if (totalSize > 102400) {
throw new Error(`Total data exceeds sync limit: ${totalSize} > 102400`);
}
// Write to sync
await chrome.storage.sync.set(syncData);
// Clear from local (optional - keep as backup)
// await chrome.storage.local.remove(Object.keys(syncData));
console.log(`Migrated ${Object.keys(syncData).length} items to sync`);
}
6.3 Data Transformation Migration {#63-data-transformation-migration}
Transform data structure during migration:
// Migrate from old format to new format
async function migrateUserData(): Promise<void> {
const oldData = await chrome.storage.local.get('userData');
if (oldData.userData && Array.isArray(oldData.userData)) {
// Transform old array format to new object format
const newData = {
users: oldData.userData,
lastUpdated: Date.now(),
version: 2,
};
await chrome.storage.local.set({ userData: newData });
console.log('Migration complete: transformed to new format');
}
}
Common Mistakes
- Storing large objects in sync storage - Exceeds 8KB per item or 100KB total limits
- Not initializing storage before reading - First read returns empty, not defaults
- Memory leaks from watchers - Always return unsubscribe functions from listeners
- Race conditions - Don’t assume sequential reads/writes; use transactions or batch operations
- Ignoring context lifecycle - Service workers terminate; don’t rely on in-memory state
- Not handling migration - Users with old data will have broken extensions after updates
Related Articles
- Storage Quickstart - Getting started with chrome.storage fundamentals
- Advanced Storage Patterns - Deep dive into schema design and batch operations
- Advanced Messaging - Cross-context communication patterns
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.