Chrome Extension Storage API Tutorial: Sync vs Local — Complete TypeScript Guide

30 min read

Chrome 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:

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:

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

  1. Don’t store large data in sync — The 100KB limit is strict
  2. Always handle missing keys — Use default values
  3. Don’t assume immediate consistency — Sync can take seconds
  4. Watch for quota errors — Always wrap set() in try-catch
  5. Remember session storage clears — Don’t persist critical data there
  6. Don’t forget to await — Forgetting await leads to race conditions
  7. 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:

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.

No previous article
No next article