Chrome Extension Storage API Tutorial: Sync vs Local — Complete TypeScript Guide
30 min readChrome Extension Storage API Tutorial: Sync vs Local — Complete TypeScript Guide
The Chrome Storage API is the backbone of most successful Chrome extensions. Whether you’re building a simple popup utility or a complex productivity suite like Tab Suspender Pro, understanding when to use chrome.storage.sync versus chrome.storage.local is crucial for creating seamless user experiences across devices.
In this comprehensive TypeScript tutorial, we’ll dive deep into both storage mechanisms, explore real-world implementation patterns, and examine how production extensions like Tab Suspender Pro leverage these APIs to manage user preferences and extension state effectively.
Understanding the Chrome Storage API
Before we compare sync vs local storage, let’s establish why the Chrome Storage API outperforms traditional web storage solutions.
Why Not localStorage?
While localStorage works in extension contexts, it comes with significant limitations:
- Synchronous operations block the main thread
- No built-in change notifications require polling
- String-only storage demands manual JSON serialization
- Limited quota of approximately 5MB
- No cross-device synchronization
The Chrome Storage API addresses all these pain points with an asynchronous, promise-friendly architecture designed specifically for extensions.
Storage Areas Overview
Chrome provides four distinct storage areas:
| Storage Area | Persistence | Sync | Quota | Use Case |
|---|---|---|---|---|
local |
Until cleared | No | 10MB | Device-specific data |
sync |
Until cleared | Yes | 100KB | User preferences |
session |
Until browser closes | No | 10MB | Temporary state |
managed |
Admin-controlled | No | Varies | Enterprise policies |
chrome.storage.local: Device-Specific Storage
chrome.storage.local is the workhorse for extension data that doesn’t need to travel across devices. It’s perfect for cached data, device-specific settings, and information that would be meaningless on other machines.
Basic TypeScript Operations
// types/storage.ts
interface ExtensionSettings {
theme: 'light' | 'dark' | 'system';
autoSuspendEnabled: boolean;
suspendAfterMinutes: number;
excludedDomains: string[];
}
class LocalStorageManager {
async saveSettings(settings: ExtensionSettings): Promise<void> {
await chrome.storage.local.set({ settings });
}
async loadSettings(): Promise<ExtensionSettings> {
const result = await chrome.storage.local.get('settings');
return result.settings ?? this.getDefaultSettings();
}
private getDefaultSettings(): ExtensionSettings {
return {
theme: 'system',
autoSuspendEnabled: true,
suspendAfterMinutes: 15,
excludedDomains: []
};
}
}
Real-World Example: Tab Suspender Pro Configuration
Tab Suspender Pro uses chrome.storage.local for data that shouldn’t sync—primarily cached tab states and analytics:
// services/tab-state-storage.ts
interface SuspendedTab {
tabId: number;
url: string;
title: string;
favicon?: string;
suspendedAt: number;
memorySaved: number;
}
interface TabStateCache {
suspendedTabs: Map<number, SuspendedTab>;
lastUpdated: number;
}
export class TabStateStorage {
private readonly STORAGE_KEY = 'tabStateCache';
async cacheSuspendedTab(tab: SuspendedTab): Promise<void> {
const cache = await this.getCache();
cache.suspendedTabs.set(tab.tabId, tab);
cache.lastUpdated = Date.now();
await chrome.storage.local.set({
[this.STORAGE_KEY]: {
suspendedTabs: Array.from(cache.suspendedTabs.entries()),
lastUpdated: cache.lastUpdated
}
});
}
async getSuspendedTabs(): Promise<SuspendedTab[]> {
const cache = await this.getCache();
return Array.from(cache.suspendedTabs.values());
}
async removeSuspendedTab(tabId: number): Promise<void> {
const cache = await this.getCache();
cache.suspendedTabs.delete(tabId);
await chrome.storage.local.set({
[this.STORAGE_KEY]: {
suspendedTabs: Array.from(cache.suspendedTabs.entries()),
lastUpdated: Date.now()
}
});
}
private async getCache(): Promise<TabStateCache> {
const result = await chrome.storage.local.get(this.STORAGE_KEY);
const data = result[this.STORAGE_KEY];
if (!data) {
return { suspendedTabs: new Map(), lastUpdated: 0 };
}
return {
suspendedTabs: new Map(data.suspendedTabs as [number, SuspendedTab][]),
lastUpdated: data.lastUpdated
};
}
}
Quota Management for Local Storage
Local storage offers 10MB by default, expandable to unlimited with the unlimitedStorage permission:
// utils/quota-manager.ts
interface StorageQuota {
bytesInUse: number;
quota: number;
usagePercent: number;
}
export class QuotaManager {
async getQuotaInfo(): Promise<StorageQuota> {
const bytesInUse = await chrome.storage.local.getBytesInUse();
const quota = 10 * 1024 * 1024; // 10MB default
return {
bytesInUse,
quota,
usagePercent: (bytesInUse / quota) * 100
};
}
async isApproachingLimit(threshold: number = 80): Promise<boolean> {
const info = await this.getQuotaInfo();
return info.usagePercent > threshold;
}
}
chrome.storage.sync: Cross-Device Synchronization
chrome.storage.sync automatically synchronizes data across all devices where the user is signed into Chrome. This is ideal for user preferences, settings, and any data that should follow the user.
When to Use Sync Storage
Choose chrome.storage.sync when:
- Users access your extension from multiple devices
- Preferences should persist across installations
- You want zero-configuration cross-device support
TypeScript Implementation
// types/sync-settings.ts
interface SyncableSettings {
userId?: string;
preferences: UserPreferences;
shortcuts: KeyboardShortcuts;
theme: ExtensionTheme;
}
interface UserPreferences {
notificationsEnabled: boolean;
soundEnabled: boolean;
autoStartEnabled: boolean;
showBadgeCount: boolean;
}
interface KeyboardShortcuts {
suspendTab: string;
restoreTab: string;
suspendAll: string;
}
interface ExtensionTheme {
mode: 'light' | 'dark' | 'system';
accentColor: string;
}
class SyncStorageManager {
private readonly SYNC_KEY = 'syncableSettings';
async saveSyncSettings(settings: Partial<SyncableSettings>): Promise<void> {
const current = await this.loadSyncSettings();
const updated = { ...current, ...settings };
await chrome.storage.sync.set({
[this.SYNC_KEY]: updated
});
}
async loadSyncSettings(): Promise<SyncableSettings> {
const result = await chrome.storage.sync.get(this.SYNC_KEY);
return result[this.SYNC_KEY] ?? this.getDefaults();
}
private getDefaults(): SyncableSettings {
return {
preferences: {
notificationsEnabled: true,
soundEnabled: false,
autoStartEnabled: true,
showBadgeCount: true
},
shortcuts: {
suspendTab: 'Ctrl+Shift+S',
restoreTab: 'Ctrl+Shift+R',
suspendAll: 'Ctrl+Shift+A'
},
theme: {
mode: 'system',
accentColor: '#4F46E5'
}
};
}
async clearSyncData(): Promise<void> {
await chrome.storage.sync.remove(this.SYNC_KEY);
}
}
Handling Sync Conflicts
When multiple devices modify data simultaneously, Chrome handles conflict resolution automatically with a “last write wins” strategy. For more complex scenarios, implement custom conflict resolution:
// utils/sync-conflict-resolver.ts
interface SyncMetadata {
lastModified: number;
deviceId: string;
deviceName: string;
}
interface ConflictResolution<T> {
resolved: T;
strategy: 'local' | 'remote' | 'merge';
}
export class SyncConflictResolver {
resolve<T>(local: T & SyncMetadata, remote: T & SyncMetadata): ConflictResolution<T> {
// If timestamps are very close, prefer local changes
const timeDiff = Math.abs(local.lastModified - remote.lastModified);
if (timeDiff < 1000) {
return {
resolved: local,
strategy: 'local'
};
}
// Otherwise, use the most recent
return {
resolved: local.lastModified > remote.lastModified ? local : remote,
strategy: local.lastModified > remote.lastModified ? 'local' : 'remote'
};
}
}
Sync Storage Best Practices
// Best practices for sync storage
// 1. Keep items small - sync has 100KB total limit
const BAD practice = {
largeCache: new Array(10000).fill('x') // Will fail!
};
const GOOD practice = {
userId: 'user_123',
preferences: { theme: 'dark' } // Small and syncable
};
// 2. Use meaningful key prefixes
await chrome.storage.sync.set({
'pref:theme': 'dark',
'pref:notifications': true,
'data:lastSync': Date.now()
});
// 3. Monitor sync status
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync') {
console.log('Sync storage changed:', changes);
}
});
Listening for Storage Changes
Both storage areas support change listeners—crucial for keeping your extension’s UI in sync:
// services/storage-listener.ts
type StorageChangeCallback = (changes: Record<string, chrome.storage.StorageChange>) => void;
export class StorageChangeListener {
private listeners: Map<string, StorageChangeCallback[]> = new Map();
constructor() {
this.initializeListener();
}
private initializeListener(): void {
chrome.storage.onChanged.addListener((changes, area) => {
const callbacks = this.listeners.get(area);
if (callbacks) {
callbacks.forEach(callback => callback(changes));
}
});
}
onLocalChange(callback: StorageChangeCallback): void {
const existing = this.listeners.get('local') ?? [];
this.listeners.set('local', [...existing, callback]);
}
onSyncChange(callback: StorageChangeCallback): void {
const existing = this.listeners.get('sync') ?? [];
this.listeners.set('sync', [...existing, callback]);
}
}
// Usage example
const listener = new StorageChangeListener();
listener.onSyncChange((changes) => {
if (changes['syncableSettings']) {
console.log('Settings synced:', changes['syncableSettings'].newValue);
// Update UI accordingly
}
});
Hybrid Approach: Best of Both Worlds
Many production extensions, including Tab Suspender Pro, use a hybrid strategy:
// services/extension-storage.ts
interface ExtensionData {
// Syncable - follows user across devices
sync: {
preferences: UserPreferences;
theme: ExtensionTheme;
shortcuts: KeyboardShortcuts;
};
// Local only - device-specific
local: {
tabCache: SuspendedTab[];
analytics: AnalyticsData;
lastActivity: number;
};
}
export class ExtensionStorage {
private sync = new SyncStorageManager();
private local = new LocalStorageManager();
private tabState = new TabStateStorage();
// Load all storage on startup
async initialize(): Promise<ExtensionData> {
const [syncData, localData] = await Promise.all([
this.sync.loadSyncSettings(),
this.local.loadSettings(),
this.tabState.getSuspendedTabs()
]);
return {
sync: syncData,
local: {
tabCache: localData,
analytics: { sessions: 0 },
lastActivity: Date.now()
}
};
}
// Save preferences to sync
async savePreferences(preferences: UserPreferences): Promise<void> {
await this.sync.saveSyncSettings({ preferences });
}
// Cache tabs locally only
async cacheTabs(tabs: SuspendedTab[]): Promise<void> {
for (const tab of tabs) {
await this.tabState.cacheSuspendedTab(tab);
}
}
}
Performance Optimization
Batch Operations
// Efficient batch operations
async function batchSaveTabs(tabs: SuspendedTab[]): Promise<void> {
// Group into chunks to avoid quota issues
const CHUNK_SIZE = 100;
const chunks = this.chunkArray(tabs, CHUNK_SIZE);
for (const chunk of chunks) {
const data: Record<string, unknown> = {};
chunk.forEach((tab, index) => {
data[`tab_${tab.tabId}`] = tab;
});
await chrome.storage.local.set(data);
}
}
private chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
Lazy Loading
// Only load what you need, when you need it
class LazyStorageLoader {
private cache: Map<string, unknown> = new Map();
async get<T>(key: string, storageArea: 'local' | 'sync' = 'local'): Promise<T | null> {
if (this.cache.has(key)) {
return this.cache.get(key) as T;
}
const area = storageArea === 'local' ? chrome.storage.local : chrome.storage.sync;
const result = await area.get(key);
if (result[key] !== undefined) {
this.cache.set(key, result[key]);
}
return result[key] as T ?? null;
}
invalidate(key: string): void {
this.cache.delete(key);
}
}
Debugging Storage Issues
// Debug utilities
export class StorageDebugger {
async dumpAllStorage(): Promise<void> {
const local = await chrome.storage.local.get(null);
const sync = await chrome.storage.sync.get(null);
console.group('Chrome Storage Debug');
console.log('Local Storage:', local);
console.log('Sync Storage:', sync);
console.log('Bytes in use (local):', await chrome.storage.local.getBytesInUse());
console.log('Bytes in use (sync):', await chrome.storage.sync.getBytesInUse());
console.groupEnd();
}
async clearAllStorage(): Promise<void> {
await chrome.storage.local.clear();
await chrome.storage.sync.clear();
console.log('All storage cleared');
}
}
Advanced Patterns and Migration Strategies
Migrating from localStorage to chrome.storage
If you’re updating an older extension, migrating from localStorage is straightforward but requires careful planning:
// migrations/storage-migrator.ts
interface MigrationResult {
success: boolean;
itemsMigrated: number;
errors: string[];
}
export class StorageMigrator {
async migrateFromLocalStorage(): Promise<MigrationResult> {
const result: MigrationResult = {
success: false,
itemsMigrated: 0,
errors: []
};
try {
// Read from localStorage
const localStorageData: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
localStorageData[key] = localStorage.getItem(key) ?? '';
}
}
// Parse and categorize data
const syncData: Record<string, unknown> = {};
const localData: Record<string, unknown> = {};
Object.entries(localStorageData).forEach(([key, value]) => {
try {
const parsed = JSON.parse(value);
// Heuristic: preferences likely should sync
if (key.includes('preference') || key.includes('setting') || key.includes('theme')) {
syncData[key] = parsed;
} else {
localData[key] = parsed;
}
} catch {
// Store as string if not valid JSON
localData[key] = value;
}
});
// Migrate to appropriate storage areas
if (Object.keys(syncData).length > 0) {
await chrome.storage.sync.set(syncData);
result.itemsMigrated += Object.keys(syncData).length;
}
if (Object.keys(localData).length > 0) {
await chrome.storage.local.set(localData);
result.itemsMigrated += Object.keys(localData).length;
}
// Clear old localStorage
localStorage.clear();
result.success = true;
} catch (error) {
result.errors.push(error instanceof Error ? error.message : 'Unknown error');
}
return result;
}
}
Type-Safe Storage Wrappers with Generics
Create fully type-safe storage abstractions:
// typesafe-storage.ts
type StorageAreaName = 'local' | 'sync' | 'session';
class TypeSafeStorage<T extends Record<string, unknown>> {
constructor(
private namespace: string,
private area: StorageAreaName = 'local'
) {}
private get storage(): typeof chrome.storage[typeof this.area] {
return chrome.storage[this.area];
}
private getKey(key: string): string {
return `${this.namespace}:${key}`;
}
async set<K extends keyof T>(key: K, value: T[K]): Promise<void> {
await this.storage.set({ [this.getKey(key as string)]: value });
}
async get<K extends keyof T>(key: K, defaultValue: T[K]): Promise<T[K]> {
const result = await this.storage.get(this.getKey(key as string));
return (result[this.getKey(key as string)] as T[K]) ?? defaultValue;
}
async getAll(): Promise<Partial<T>> {
const result = await this.storage.get(null);
const prefixed: Partial<T> = {};
Object.entries(result).forEach(([fullKey, value]) => {
if (fullKey.startsWith(`${this.namespace}:`)) {
const key = fullKey.slice(this.namespace.length + 1) as keyof T;
prefixed[key] = value as T[keyof T];
}
});
return prefixed;
}
async remove<K extends keyof T>(key: K): Promise<void> {
await this.storage.remove(this.getKey(key as string));
}
async clear(): Promise<void> {
const all = await this.getAll();
const keys = Object.keys(all).map(k => this.getKey(k));
await this.storage.remove(keys);
}
}
// Usage with full type safety
interface MyExtensionSettings {
theme: 'light' | 'dark';
fontSize: number;
notifications: boolean;
}
const settingsStorage = new TypeSafeStorage<MyExtensionSettings>('settings', 'sync');
// Fully typed - IDE autocomplete works!
await settingsStorage.set('theme', 'dark');
const theme = await settingsStorage.get('theme', 'light');
Implementing Undo/Redo for Storage Changes
Build a history system for important storage changes:
// utils/storage-history.ts
interface StorageHistoryEntry<T> {
timestamp: number;
key: string;
previousValue: T;
newValue: T;
}
class StorageHistoryManager {
private readonly HISTORY_KEY = 'storageChangeHistory';
private readonly MAX_HISTORY = 50;
async recordChange<T>(key: string, previousValue: T, newValue: T): Promise<void> {
const history = await this.getHistory();
history.unshift({
timestamp: Date.now(),
key,
previousValue,
newValue
});
// Trim history to max size
if (history.length > this.MAX_HISTORY) {
history.length = this.MAX_HISTORY;
}
await chrome.storage.local.set({
[this.HISTORY_KEY]: history
});
}
async undoLastChange(key: string): Promise<boolean> {
const history = await this.getHistory();
const lastChange = history.find(h => h.key === key);
if (!lastChange) {
return false;
}
await chrome.storage.local.set({
[key]: lastChange.previousValue
});
return true;
}
private async getHistory(): Promise<StorageHistoryEntry<unknown>[]> {
const result = await chrome.storage.local.get(this.HISTORY_KEY);
return result[this.HISTORY_KEY] ?? [];
}
}
Working with Storage in Service Workers
Service workers have unique considerations for storage:
// service-worker-storage.ts
// Service workers can be terminated at any time, so always use async storage
// ❌ Bad - assuming sync access
chrome.storage.local.get('key', callback); // Callback might never fire if SW terminates
// ✅ Good - async/await pattern
async function getData(): Promise<unknown> {
const result = await chrome.storage.local.get('key');
return result.key;
}
// Handle service worker lifecycle
chrome.runtime.onStartup.addListener(async () => {
// Service worker starting fresh - rehydrate state from storage
const settings = await chrome.storage.local.get('cachedSettings');
// Initialize your extension state
});
Storage Encryption for Sensitive Data
For sensitive data that needs protection:
// utils/secure-storage.ts
// Note: For true security, use the identity API or native messaging
// This provides basic obfuscation only
class SecureStorage {
private readonly ENCRYPTION_KEY = 'secureStorageKey';
async encrypt(data: string): Promise<string> {
// Simple encoding - not cryptographically secure!
// For production, use chrome.identity or Web Crypto API
return btoa(data);
}
async decrypt(encoded: string): Promise<string> {
return atob(encoded);
}
async setSecure<T>(key: string, value: T): Promise<void> {
const json = JSON.stringify(value);
const encrypted = await this.encrypt(json);
await chrome.storage.local.set({ [key]: encrypted });
}
async getSecure<T>(key: string): Promise<T | null> {
const result = await chrome.storage.local.get(key);
const encrypted = result[key];
if (!encrypted) return null;
try {
const decrypted = await this.decrypt(encrypted);
return JSON.parse(decrypted) as T;
} catch {
return null;
}
}
}
Testing Storage Implementations
// tests/storage.test.ts
// Mock chrome.storage for unit testing
const mockStorage = {
local: new Map<string, unknown>(),
sync: new Map<string, unknown>(),
set: jest.fn((items: Record<string, unknown>) => Promise.resolve()),
get: jest.fn((keys: string | string[] | null) => Promise.resolve({})),
remove: jest.fn((keys: string | string[]) => Promise.resolve()),
clear: jest.fn(() => Promise.resolve()),
getBytesInUse: jest.fn(() => Promise.resolve(0))
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('LocalStorageManager', () => {
it('should save and load settings', async () => {
const manager = new LocalStorageManager();
await manager.saveSettings({
theme: 'dark',
autoSuspendEnabled: true,
suspendAfterMinutes: 30,
excludedDomains: ['example.com']
});
expect(mockStorage.local.set).toHaveBeenCalled();
});
});
Common Pitfalls to Avoid
- Don’t store large data in sync — The 100KB limit is strict
- Always handle missing keys — Use default values
- Don’t assume immediate consistency — Sync can take seconds
- Watch for quota errors — Always wrap set() in try-catch
- Remember session storage clears — Don’t persist critical data there
- Don’t forget to await — Forgetting await leads to race conditions
- Avoid storing functions — Only serializable data works
Conclusion: Making the Right Choice
For Tab Suspender Pro and similar extensions, the storage strategy breaks down simply:
chrome.storage.sync: User preferences, themes, shortcuts, account settingschrome.storage.local: Cached tab states, analytics, temporary datachrome.storage.session: UI state, modal flags, ephemeral data
This hybrid approach provides the best user experience—preferences follow them across devices while heavy data stays local and doesn’t burden sync infrastructure.
The key to success is understanding your data’s characteristics: Does it need to sync? How large is it? How often does it change? Answer these questions, and you’ll build a robust storage layer that scales with your extension.
Ready to build your own extension? Start with clear data partitioning, and your users will thank you for the seamless experience across all their devices.
For more insights into building production-ready Chrome extensions, explore the Chrome Extension Guide — your complete reference for creating powerful browser extensions with the latest Chrome APIs.
Visit zovo.one for more browser optimization tools, extensions, and productivity resources.
This guide was last updated in 2026. Chrome Storage API features and limits may vary based on your Chrome version and extension manifest version.