Skip to content

Chrome Extension Development Guide

Architecture Overview

Chrome extensions are modular programs that customize the browsing experience. They're built with web technologies (HTML, CSS, JavaScript) and run in isolated contexts with specific permissions.

Key Components: - Manifest: Configuration file defining extension metadata, permissions, and entry points - Background Scripts: Run persistently or event-driven, handle browser events, state management - Content Scripts: Inject into web pages, can read/modify DOM, limited API access - Popup: Optional UI that appears when clicking extension icon - Options Page: Settings interface for user preferences - DevTools Pages: Custom panels in browser DevTools (advanced)

Execution Contexts: 1. Extension Context - Full chrome. API access (background, popup, options) 2. Content Script Context - Limited API access, can access page DOM 3. Injected Script Context - Runs in page's JavaScript context (no chrome. APIs)


Manifest v3 Setup (Current Standard)

manifest.json

{
  "manifest_version": 3,
  "name": "Extension Name",
  "version": "1.0.0",
  "description": "What your extension does",

  // Icon sizes for different contexts
  "icons": {
    "16": "icons/icon16.png",    // Favicon, context menus
    "48": "icons/icon48.png",    // Extension management page
    "128": "icons/icon128.png"   // Chrome Web Store, installation
  },

  // Defines the extension's toolbar icon and popup
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    },
    "default_title": "Extension tooltip"
  },

  // Event-driven background service worker (replaces persistent background pages)
  "background": {
    "service_worker": "background.js",
    "type": "module"  // Optional: enables ES6 imports
  },

  // Scripts injected into web pages
  "content_scripts": [{
    "matches": ["https://*/*"],      // URL patterns to inject into
    "js": ["content.js"],
    "css": ["content.css"],          // Optional styling
    "run_at": "document_idle",       // Options: document_start, document_end, document_idle
    "all_frames": false              // true = inject into iframes too
  }],

  // Permissions your extension needs
  "permissions": [
    "storage",        // chrome.storage API
    "tabs",           // chrome.tabs API (basic tab info)
    "activeTab",      // Access active tab on user action (click icon)
    "scripting",      // chrome.scripting API (dynamic injection)
    "contextMenus",   // Right-click menu items
    "notifications",  // Desktop notifications
    "alarms",         // Scheduled tasks
    "cookies"         // Access cookies
  ],

  // Host permissions for network requests or content script injection
  "host_permissions": [
    "https://*.example.com/*",
    "https://api.service.com/*"
  ],

  // Optional permissions (requested at runtime, not install time)
  "optional_permissions": ["downloads", "bookmarks"],
  "optional_host_permissions": ["https://*/*"],

  // Settings/options page
  "options_page": "options.html",
  // or
  "options_ui": {
    "page": "options.html",
    "open_in_tab": false  // false = embedded in chrome://extensions
  },

  // Content Security Policy (limits what scripts can run)
  "content_security_policy": {
    "extension_pages": "script-src 'self'; object-src 'self'"
  },

  // Web-accessible resources (files that web pages can access)
  "web_accessible_resources": [{
    "resources": ["images/*", "injected.js"],
    "matches": ["https://*/*"]
  }]
}

File Structure

extension/
├── manifest.json           # Required: extension config
├── background.js           # Service worker (event handling)
├── content.js             # Injected into web pages
├── popup.html             # Extension icon popup UI
├── popup.js               # Popup logic
├── options.html           # Settings page
├── options.js             # Settings logic
├── styles/
│   ├── popup.css
│   └── content.css
├── icons/
│   ├── icon16.png
│   ├── icon48.png
│   └── icon128.png
└── lib/                   # Shared utilities
    └── utils.js

Background Service Worker (background.js)

Purpose: Handles events, manages state, coordinates extension logic. Runs when needed, terminates when idle (Manifest v3 behavior).

// background.js

// Service workers don't have persistent state - terminated when idle
// Use chrome.storage for persistence

// Listen for extension install/update
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('Extension installed');
    // Initialize default settings
    chrome.storage.sync.set({ setting1: true });

    // Open onboarding page
    chrome.tabs.create({ url: 'onboarding.html' });
  } else if (details.reason === 'update') {
    console.log('Extension updated to', chrome.runtime.getManifest().version);
  }
});

// Listen for toolbar icon clicks (if no popup defined)
chrome.action.onClicked.addListener((tab) => {
  // Execute content script dynamically
  chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ['content.js']
  });
});

// Listen for messages from content scripts or popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  console.log('Message received:', message);
  console.log('From tab:', sender.tab?.id);

  if (message.action === 'getData') {
    // Async operations require returning true and calling sendResponse later
    chrome.storage.local.get('data', (result) => {
      sendResponse({ data: result.data });
    });
    return true; // CRITICAL: keeps message channel open for async response
  }

  if (message.action === 'makeRequest') {
    // Fetch from background (content scripts have CORS restrictions)
    fetch('https://api.example.com/data')
      .then(res => res.json())
      .then(data => sendResponse({ success: true, data }))
      .catch(err => sendResponse({ success: false, error: err.message }));
    return true;
  }

  // Synchronous response (no return needed)
  sendResponse({ received: true });
});

// Listen for tab updates
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  // changeInfo contains: status, url, title, etc.
  if (changeInfo.status === 'complete' && tab.url) {
    console.log('Tab loaded:', tab.url);
  }
});

// Context menu (right-click menu)
chrome.contextMenus.create({
  id: 'myMenuItem',
  title: 'Process with Extension',
  contexts: ['selection'], // selection, page, link, image, etc.
  documentUrlPatterns: ['https://*/*'] // Only show on HTTPS pages
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  if (info.menuItemId === 'myMenuItem') {
    console.log('Selected text:', info.selectionText);
    // Send to content script or process here
    chrome.tabs.sendMessage(tab.id, {
      action: 'processText',
      text: info.selectionText
    });
  }
});

// Alarms (scheduled tasks - persist across service worker restarts)
chrome.alarms.create('periodicTask', {
  periodInMinutes: 60 // Run every hour
});

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'periodicTask') {
    console.log('Running periodic task');
    // Do work here
  }
});

// Badge text (number on extension icon)
chrome.action.setBadgeText({ text: '5' });
chrome.action.setBadgeBackgroundColor({ color: '#FF0000' });

// Notifications
chrome.notifications.create('notif-id', {
  type: 'basic',
  iconUrl: 'icons/icon48.png',
  title: 'Notification Title',
  message: 'Notification message',
  priority: 2
});

Key Limitations: - No DOM access (use content scripts) - No window object (it's a service worker, not a page) - Can terminate anytime - use chrome.storage for persistence - Can't use localStorage or sessionStorage


Content Scripts (content.js)

Purpose: Run in the context of web pages, can read/modify DOM, limited chrome.* API access.

// content.js

// This script runs on every page matching manifest patterns
console.log('Content script loaded on:', window.location.href);

// Access page DOM directly
const pageTitle = document.title;
const allLinks = document.querySelectorAll('a');

// Modify page content
const banner = document.createElement('div');
banner.id = 'extension-banner';
banner.textContent = 'Extension Active';
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:blue;color:white;padding:10px;z-index:999999;';
document.body.appendChild(banner);

// Listen for messages from background or popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'processText') {
    const result = processText(message.text);
    sendResponse({ result });
  }

  if (message.action === 'getPageData') {
    const data = {
      title: document.title,
      url: window.location.href,
      links: Array.from(document.querySelectorAll('a')).map(a => a.href)
    };
    sendResponse(data);
  }

  // For async operations
  if (message.action === 'asyncTask') {
    doAsyncWork().then(result => {
      sendResponse({ success: true, result });
    });
    return true; // Keep channel open
  }
});

// Send message to background
chrome.runtime.sendMessage({
  action: 'pageLoaded',
  url: window.location.href
}, (response) => {
  console.log('Background responded:', response);
});

// Listen to page events
document.addEventListener('click', (e) => {
  if (e.target.tagName === 'A') {
    console.log('Link clicked:', e.target.href);
    // Can send to background for logging
    chrome.runtime.sendMessage({ action: 'linkClicked', url: e.target.href });
  }
});

// Observe DOM changes
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.addedNodes.length) {
      console.log('Nodes added to page');
    }
  });
});
observer.observe(document.body, { childList: true, subtree: true });

// Access chrome.storage (one of few APIs available)
chrome.storage.sync.get('settings', (result) => {
  console.log('Settings:', result.settings);
});

// CANNOT access most chrome.* APIs (tabs, windows, downloads, etc.)
// CANNOT make cross-origin fetch without host_permissions in manifest

Content Script Isolation: - Runs in isolated JavaScript context (can't access page's JavaScript variables) - Shares the DOM with the page - Has separate window object from page

To access page's JavaScript context, inject a script:

// content.js - inject script into page context
const script = document.createElement('script');
script.src = chrome.runtime.getURL('injected.js');
script.onload = function() {
  this.remove();
};
(document.head || document.documentElement).appendChild(script);

// Listen for messages from injected script
window.addEventListener('message', (event) => {
  // Verify source
  if (event.source !== window) return;
  if (event.data.type === 'FROM_PAGE') {
    console.log('Data from page:', event.data.payload);
  }
});
// injected.js - runs in page context (must be in web_accessible_resources)
// Can access page variables and functions
console.log('Injected script running');
console.log('Page variable:', window.somePageVariable);

// Send data back to content script
window.postMessage({
  type: 'FROM_PAGE',
  payload: { data: 'from page context' }
}, '*');

Purpose: UI that appears when clicking extension icon. Each time it opens, scripts reload (no persistence).

<!-- popup.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="styles/popup.css">
  <!-- Inline styles OK, but no inline scripts in Manifest v3 -->
</head>
<body>
  <div class="container">
    <h1>Extension Popup</h1>
    <button id="actionBtn">Perform Action</button>
    <div id="status"></div>
    <div id="data-display"></div>
  </div>
  <script src="popup.js"></script>
</body>
</html>
// popup.js

// Popup reloads every time it's opened - no persistent state
// Use chrome.storage for data that needs to persist

document.addEventListener('DOMContentLoaded', async () => {
  const actionBtn = document.getElementById('actionBtn');
  const status = document.getElementById('status');
  const dataDisplay = document.getElementById('data-display');

  // Load saved data
  const { savedData } = await chrome.storage.local.get('savedData');
  if (savedData) {
    dataDisplay.textContent = JSON.stringify(savedData, null, 2);
  }

  actionBtn.addEventListener('click', async () => {
    // Get active tab
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    // Send message to content script in active tab
    chrome.tabs.sendMessage(tab.id, { action: 'getPageData' }, (response) => {
      if (chrome.runtime.lastError) {
        // Content script not loaded or tab not accessible
        status.textContent = 'Error: ' + chrome.runtime.lastError.message;
        return;
      }

      status.textContent = 'Data received!';
      dataDisplay.textContent = JSON.stringify(response, null, 2);

      // Save to storage
      chrome.storage.local.set({ savedData: response });
    });
  });

  // Or execute script dynamically if content script isn't always loaded
  document.getElementById('injectBtn')?.addEventListener('click', async () => {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

    try {
      // Inject and execute code
      const results = await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        func: () => {
          // This function runs in page context
          return {
            title: document.title,
            linksCount: document.querySelectorAll('a').length
          };
        }
      });

      console.log('Result:', results[0].result);
    } catch (err) {
      console.error('Injection failed:', err);
    }
  });

  // Send message to background
  chrome.runtime.sendMessage({ action: 'getData' }, (response) => {
    console.log('Background response:', response);
  });
});

// Popup-specific: listen for messages (less common than sending)
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'updatePopup') {
    document.getElementById('status').textContent = message.text;
  }
});

Options Page (options.html + options.js)

Purpose: Settings/preferences UI for users. Can be embedded in chrome://extensions or open in a tab.

<!-- options.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Extension Settings</title>
  <link rel="stylesheet" href="styles/options.css">
</head>
<body>
  <div class="container">
    <h1>Extension Settings</h1>

    <!-- Toggle settings -->
    <div class="setting">
      <label>
        <input type="checkbox" id="enableFeature">
        Enable main feature
      </label>
    </div>

    <div class="setting">
      <label>
        <input type="checkbox" id="showNotifications">
        Show notifications
      </label>
    </div>

    <!-- Radio buttons -->
    <div class="setting">
      <label>Theme:</label>
      <div>
        <label><input type="radio" name="theme" value="light"> Light</label>
        <label><input type="radio" name="theme" value="dark"> Dark</label>
        <label><input type="radio" name="theme" value="auto"> Auto</label>
      </div>
    </div>

    <!-- Select dropdown -->
    <div class="setting">
      <label for="updateInterval">Update interval:</label>
      <select id="updateInterval">
        <option value="5">5 minutes</option>
        <option value="15">15 minutes</option>
        <option value="30">30 minutes</option>
        <option value="60">1 hour</option>
      </select>
    </div>

    <!-- Text input -->
    <div class="setting">
      <label for="apiKey">API Key:</label>
      <input type="text" id="apiKey" placeholder="Enter your API key">
    </div>

    <!-- Number input -->
    <div class="setting">
      <label for="maxItems">Max items to display:</label>
      <input type="number" id="maxItems" min="1" max="100" value="10">
    </div>

    <!-- Textarea -->
    <div class="setting">
      <label for="customRules">Custom rules (one per line):</label>
      <textarea id="customRules" rows="5"></textarea>
    </div>

    <!-- URL patterns -->
    <div class="setting">
      <label for="blockedSites">Blocked sites (one per line):</label>
      <textarea id="blockedSites" rows="4" placeholder="example.com&#10;*.ads.com"></textarea>
    </div>

    <!-- Color picker -->
    <div class="setting">
      <label for="highlightColor">Highlight color:</label>
      <input type="color" id="highlightColor" value="#ffff00">
    </div>

    <!-- Buttons -->
    <div class="actions">
      <button id="save">Save Settings</button>
      <button id="reset">Reset to Defaults</button>
      <button id="export">Export Settings</button>
      <button id="import">Import Settings</button>
    </div>

    <!-- Status message -->
    <div id="status" class="status"></div>

    <!-- Advanced section (collapsible) -->
    <details>
      <summary>Advanced Settings</summary>
      <div class="setting">
        <label>
          <input type="checkbox" id="debugMode">
          Enable debug mode
        </label>
      </div>
      <div class="setting">
        <label for="customEndpoint">Custom API endpoint:</label>
        <input type="url" id="customEndpoint" placeholder="https://api.example.com">
      </div>
    </details>

    <!-- Hidden file input for import -->
    <input type="file" id="fileInput" accept=".json" style="display: none;">
  </div>

  <script src="options.js"></script>
</body>
</html>
/* styles/options.css */
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  max-width: 600px;
  margin: 20px auto;
  padding: 20px;
  background: #f5f5f5;
}

.container {
  background: white;
  padding: 30px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

h1 {
  margin-top: 0;
  color: #333;
}

.setting {
  margin-bottom: 20px;
}

.setting label {
  display: block;
  margin-bottom: 5px;
  font-weight: 500;
  color: #555;
}

input[type="text"],
input[type="url"],
input[type="number"],
select,
textarea {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 14px;
  box-sizing: border-box;
}

input[type="checkbox"],
input[type="radio"] {
  margin-right: 8px;
}

.actions {
  display: flex;
  gap: 10px;
  margin-top: 30px;
}

button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background 0.2s;
}

#save {
  background: #4285f4;
  color: white;
}

#save:hover {
  background: #3367d6;
}

#reset, #export, #import {
  background: #f1f1f1;
  color: #333;
}

#reset:hover, #export:hover, #import:hover {
  background: #e0e0e0;
}

.status {
  margin-top: 15px;
  padding: 10px;
  border-radius: 4px;
  display: none;
}

.status.success {
  background: #d4edda;
  color: #155724;
  display: block;
}

.status.error {
  background: #f8d7da;
  color: #721c24;
  display: block;
}

details {
  margin-top: 30px;
  padding: 15px;
  background: #f9f9f9;
  border-radius: 4px;
}

summary {
  cursor: pointer;
  font-weight: 500;
  color: #4285f4;
}
// options.js

// Default settings
const DEFAULT_SETTINGS = {
  enableFeature: true,
  showNotifications: true,
  theme: 'auto',
  updateInterval: 15,
  apiKey: '',
  maxItems: 10,
  customRules: '',
  blockedSites: '',
  highlightColor: '#ffff00',
  debugMode: false,
  customEndpoint: ''
};

// DOM elements
const elements = {
  enableFeature: document.getElementById('enableFeature'),
  showNotifications: document.getElementById('showNotifications'),
  updateInterval: document.getElementById('updateInterval'),
  apiKey: document.getElementById('apiKey'),
  maxItems: document.getElementById('maxItems'),
  customRules: document.getElementById('customRules'),
  blockedSites: document.getElementById('blockedSites'),
  highlightColor: document.getElementById('highlightColor'),
  debugMode: document.getElementById('debugMode'),
  customEndpoint: document.getElementById('customEndpoint'),
  save: document.getElementById('save'),
  reset: document.getElementById('reset'),
  export: document.getElementById('export'),
  import: document.getElementById('import'),
  status: document.getElementById('status'),
  fileInput: document.getElementById('fileInput')
};

// Get theme radio buttons
const themeRadios = document.querySelectorAll('input[name="theme"]');

// Load settings on page load
document.addEventListener('DOMContentLoaded', loadSettings);

// Save button
elements.save.addEventListener('click', saveSettings);

// Reset button
elements.reset.addEventListener('click', resetSettings);

// Export button
elements.export.addEventListener('click', exportSettings);

// Import button
elements.import.addEventListener('click', () => {
  elements.fileInput.click();
});

elements.fileInput.addEventListener('change', importSettings);

// Auto-save on change (optional - can be annoying for text inputs)
// Uncomment if you want instant saving
/*
document.querySelectorAll('input, select, textarea').forEach(el => {
  el.addEventListener('change', saveSettings);
});
*/

/**
 * Load settings from storage and populate form
 */
async function loadSettings() {
  try {
    // Get settings from storage (defaults to DEFAULT_SETTINGS if not set)
    const settings = await chrome.storage.sync.get(DEFAULT_SETTINGS);

    // Populate form fields
    elements.enableFeature.checked = settings.enableFeature;
    elements.showNotifications.checked = settings.showNotifications;
    elements.updateInterval.value = settings.updateInterval;
    elements.apiKey.value = settings.apiKey;
    elements.maxItems.value = settings.maxItems;
    elements.customRules.value = settings.customRules;
    elements.blockedSites.value = settings.blockedSites;
    elements.highlightColor.value = settings.highlightColor;
    elements.debugMode.checked = settings.debugMode;
    elements.customEndpoint.value = settings.customEndpoint;

    // Set theme radio button
    themeRadios.forEach(radio => {
      radio.checked = radio.value === settings.theme;
    });

    console.log('Settings loaded:', settings);
  } catch (error) {
    showStatus('Failed to load settings: ' + error.message, 'error');
    console.error('Load error:', error);
  }
}

/**
 * Save settings to storage
 */
async function saveSettings() {
  try {
    // Get selected theme
    const selectedTheme = document.querySelector('input[name="theme"]:checked')?.value || 'auto';

    // Collect all settings
    const settings = {
      enableFeature: elements.enableFeature.checked,
      showNotifications: elements.showNotifications.checked,
      theme: selectedTheme,
      updateInterval: parseInt(elements.updateInterval.value),
      apiKey: elements.apiKey.value.trim(),
      maxItems: parseInt(elements.maxItems.value),
      customRules: elements.customRules.value.trim(),
      blockedSites: elements.blockedSites.value.trim(),
      highlightColor: elements.highlightColor.value,
      debugMode: elements.debugMode.checked,
      customEndpoint: elements.customEndpoint.value.trim()
    };

    // Validate settings
    if (settings.maxItems < 1 || settings.maxItems > 100) {
      showStatus('Max items must be between 1 and 100', 'error');
      return;
    }

    if (settings.customEndpoint && !isValidUrl(settings.customEndpoint)) {
      showStatus('Custom endpoint must be a valid URL', 'error');
      return;
    }

    // Save to storage
    await chrome.storage.sync.set(settings);

    showStatus('Settings saved successfully!', 'success');
    console.log('Settings saved:', settings);

    // Notify other extension parts that settings changed
    chrome.runtime.sendMessage({ action: 'settingsUpdated', settings });

    // Hide status after 3 seconds
    setTimeout(() => {
      elements.status.style.display = 'none';
    }, 3000);

  } catch (error) {
    showStatus('Failed to save settings: ' + error.message, 'error');
    console.error('Save error:', error);
  }
}

/**
 * Reset settings to defaults
 */
async function resetSettings() {
  if (!confirm('Reset all settings to defaults?')) {
    return;
  }

  try {
    // Clear storage and set defaults
    await chrome.storage.sync.clear();
    await chrome.storage.sync.set(DEFAULT_SETTINGS);

    // Reload form
    await loadSettings();

    showStatus('Settings reset to defaults', 'success');

    // Notify other parts
    chrome.runtime.sendMessage({ action: 'settingsReset' });

  } catch (error) {
    showStatus('Failed to reset settings: ' + error.message, 'error');
    console.error('Reset error:', error);
  }
}

/**
 * Export settings to JSON file
 */
async function exportSettings() {
  try {
    const settings = await chrome.storage.sync.get(null);

    // Create JSON blob
    const dataStr = JSON.stringify(settings, null, 2);
    const blob = new Blob([dataStr], { type: 'application/json' });
    const url = URL.createObjectURL(blob);

    // Create download link
    const a = document.createElement('a');
    a.href = url;
    a.download = `extension-settings-${Date.now()}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);

    showStatus('Settings exported successfully', 'success');

  } catch (error) {
    showStatus('Failed to export settings: ' + error.message, 'error');
    console.error('Export error:', error);
  }
}

/**
 * Import settings from JSON file
 */
async function importSettings(event) {
  const file = event.target.files[0];
  if (!file) return;

  try {
    const text = await file.text();
    const settings = JSON.parse(text);

    // Validate imported data
    if (typeof settings !== 'object') {
      throw new Error('Invalid settings file format');
    }

    // Merge with defaults to ensure all keys exist
    const mergedSettings = { ...DEFAULT_SETTINGS, ...settings };

    // Save imported settings
    await chrome.storage.sync.set(mergedSettings);

    // Reload form
    await loadSettings();

    showStatus('Settings imported successfully', 'success');

    // Notify other parts
    chrome.runtime.sendMessage({ action: 'settingsUpdated', settings: mergedSettings });

  } catch (error) {
    showStatus('Failed to import settings: ' + error.message, 'error');
    console.error('Import error:', error);
  } finally {
    // Reset file input
    elements.fileInput.value = '';
  }
}

/**
 * Show status message
 */
function showStatus(message, type) {
  elements.status.textContent = message;
  elements.status.className = `status ${type}`;
  elements.status.style.display = 'block';
}

/**
 * Validate URL
 */
function isValidUrl(string) {
  try {
    const url = new URL(string);
    return url.protocol === 'http:' || url.protocol === 'https:';
  } catch {
    return false;
  }
}

// Listen for storage changes from other extension parts
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName === 'sync') {
    console.log('Settings changed externally:', changes);
    // Optionally reload form
    // loadSettings();
  }
});

Using settings in other parts:

// background.js - Listen for settings updates
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === 'settingsUpdated') {
    console.log('Settings updated:', message.settings);
    // Apply new settings
    applySettings(message.settings);
  }
});

// Or listen to storage changes directly
chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName === 'sync') {
    // Check which settings changed
    if (changes.enableFeature) {
      console.log('Feature toggled:', changes.enableFeature.newValue);
    }
    if (changes.updateInterval) {
      // Restart alarm with new interval
      chrome.alarms.clear('updateAlarm');
      chrome.alarms.create('updateAlarm', {
        periodInMinutes: changes.updateInterval.newValue
      });
    }
  }
});

// content.js - Use settings
async function init() {
  const settings = await chrome.storage.sync.get([
    'enableFeature',
    'highlightColor',
    'blockedSites'
  ]);

  if (!settings.enableFeature) {
    return; // Feature disabled
  }

  // Use settings
  applyHighlight(settings.highlightColor);

  // Check if current site is blocked
  const blockedList = settings.blockedSites.split('\n').filter(s => s.trim());
  const isBlocked = blockedList.some(pattern => 
    matchesPattern(window.location.hostname, pattern)
  );
}

Opening options page programmatically:

// From popup or background
chrome.runtime.openOptionsPage();

// Or open in new tab explicitly
chrome.tabs.create({ url: 'options.html' });

Storage

Chrome extensions have 3 storage areas:

// chrome.storage.sync - syncs across user's devices (quota: 100KB total, 8KB per item)
chrome.storage.sync.set({ key: 'value' }, () => {
  console.log('Saved to sync storage');
});

chrome.storage.sync.get('key', (result) => {
  console.log('Value:', result.key);
});

// chrome.storage.local - local only, higher quota (10MB Manifest v3)
chrome.storage.local.set({ largeData: bigObject });

// chrome.storage.session - cleared when browser closes (Manifest v3, 10MB)
chrome.storage.session.set({ tempData: 'temporary' });

// Get multiple keys
chrome.storage.local.get(['key1', 'key2'], (result) => {
  console.log(result.key1, result.key2);
});

// Get all data
chrome.storage.local.get(null, (items) => {
  console.log('All stored data:', items);
});

// Remove
chrome.storage.local.remove('key');
chrome.storage.local.clear(); // Remove all

// Listen for changes (works across all extension contexts)
chrome.storage.onChanged.addListener((changes, areaName) => {
  console.log('Storage area:', areaName); // 'sync', 'local', or 'session'
  for (let key in changes) {
    console.log(`${key} changed from ${changes[key].oldValue} to ${changes[key].newValue}`);
  }
});

// Async/await pattern (Manifest v3)
async function saveData() {
  await chrome.storage.local.set({ data: 'value' });
  const result = await chrome.storage.local.get('data');
  console.log(result.data);
}

Pros: - Async, non-blocking - Syncs across devices (sync storage) - Works in all extension contexts - Quota monitoring available

Cons: - Smaller quotas than IndexedDB - Sync storage has rate limits - Values must be JSON-serializable


Communication Patterns

// From popup
chrome.runtime.sendMessage({ action: 'doSomething' }, (response) => {
  console.log(response);
});

// From background (less common, popup might be closed)
chrome.runtime.sendMessage({ action: 'updateUI', data: 'new data' });

Content Script ↔ Background

// From content script
chrome.runtime.sendMessage({ action: 'logClick', url: location.href });

// From background to specific tab
chrome.tabs.sendMessage(tabId, { action: 'highlightText' }, (response) => {
  console.log(response);
});

// Broadcast to all tabs
chrome.tabs.query({}, (tabs) => {
  tabs.forEach(tab => {
    chrome.tabs.sendMessage(tab.id, { action: 'refresh' });
  });
});

Long-lived Connections

For continuous communication (e.g., DevTools):

// From content script
const port = chrome.runtime.connect({ name: 'content-connection' });
port.postMessage({ action: 'init' });
port.onMessage.addListener((msg) => {
  console.log('Received:', msg);
});

// From background
chrome.runtime.onConnect.addListener((port) => {
  console.log('Connected:', port.name);
  port.onMessage.addListener((msg) => {
    console.log('Message:', msg);
    port.postMessage({ response: 'processed' });
  });

  port.onDisconnect.addListener(() => {
    console.log('Port disconnected');
  });
});

Best Practices

Performance

  1. Minimize content script size - They're injected into every matching page
  2. Use run_at: "document_idle" for content scripts (unless you need earlier)
  3. Lazy load in service worker - Import modules only when needed
  4. Debounce frequent operations - Don't spam storage writes or messages
  5. Use chrome.alarms instead of setTimeout/setInterval in background (survives service worker restarts)

Security

  1. Never use eval() or inline scripts - CSP blocks them in Manifest v3
  2. Validate all inputs - Especially from web pages (XSS risk)
  3. Use HTTPS for external requests
  4. Minimize permissions - Only request what you need
  5. Sanitize DOM insertions - Use textContent not innerHTML when possible
  6. Don't trust messages from content scripts - Web pages can spoof them

Architecture

  1. Background = coordinator - Handle business logic, API calls, state
  2. Content scripts = page interface - Read/modify DOM, relay to background
  3. Storage = single source of truth - Background service worker can restart anytime
  4. Options page = settings UI - Let users configure behavior

Debugging

// Check for errors after async operations
if (chrome.runtime.lastError) {
  console.error(chrome.runtime.lastError.message);
}

// Proper error handling
chrome.tabs.query({}, (tabs) => {
  if (chrome.runtime.lastError) {
    console.error('Query failed:', chrome.runtime.lastError);
    return;
  }
  // Use tabs
});

// Or with promises (Manifest v3)
try {
  const tabs = await chrome.tabs.query({});
} catch (error) {
  console.error('Query failed:', error);
}

Common Pitfalls

  1. Service worker termination - Don't rely on global variables; use chrome.storage
  2. Content script context - Can't access page's JavaScript; inject script if needed
  3. Message channel closing - Must return true for async sendResponse
  4. Manifest v2 → v3 migration - Background pages → service workers, executeScriptchrome.scripting
  5. CORS in content scripts - Make API calls from background instead
  6. Cross-origin restrictions - Need host_permissions in manifest

Development Workflow

Loading Unpacked Extension

  1. Navigate to chrome://extensions
  2. Enable "Developer mode" (top right)
  3. Click "Load unpacked"
  4. Select your extension directory
  5. Extension appears with ID

Debugging

  • Background service worker: Click "service worker" link on extension card → opens DevTools
  • Popup: Right-click popup → Inspect
  • Content scripts: Open page DevTools → Content scripts appear under Sources
  • Errors: Check extension card for error button; click for stack trace

Updating During Development

  • Code changes: Click reload icon on extension card
  • Manifest changes: Must reload extension
  • Content scripts: Reload extension + refresh page where they run

Testing

// Test message passing
chrome.runtime.sendMessage({ action: 'test' }, (response) => {
  console.assert(response.success, 'Test failed');
});

// Test storage
await chrome.storage.local.set({ test: 'value' });
const { test } = await chrome.storage.local.get('test');
console.assert(test === 'value', 'Storage test failed');

Common APIs

Tabs

// Get active tab
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

// Create tab
await chrome.tabs.create({ url: 'https://example.com', active: false });

// Update tab
await chrome.tabs.update(tabId, { url: 'https://new-url.com' });

// Close tab
await chrome.tabs.remove(tabId);

// Duplicate tab
await chrome.tabs.duplicate(tabId);

// Get all tabs in window
const tabs = await chrome.tabs.query({ currentWindow: true });

Scripting (Manifest v3)

// Execute script in tab
await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  files: ['content.js']
});

// Execute function
await chrome.scripting.executeScript({
  target: { tabId: tab.id },
  func: (color) => {
    document.body.style.backgroundColor = color;
  },
  args: ['red']
});

// Inject CSS
await chrome.scripting.insertCSS({
  target: { tabId: tab.id },
  css: 'body { background: blue; }'
});

Bookmarks

// Requires 'bookmarks' permission
const bookmarks = await chrome.bookmarks.getTree();

await chrome.bookmarks.create({
  parentId: 'folder_id',
  title: 'My Bookmark',
  url: 'https://example.com'
});

Downloads

// Requires 'downloads' permission
await chrome.downloads.download({
  url: 'https://example.com/file.pdf',
  filename: 'my-file.pdf'
});

Cookies

// Requires 'cookies' permission + host_permissions
const cookies = await chrome.cookies.getAll({ domain: 'example.com' });

await chrome.cookies.set({
  url: 'https://example.com',
  name: 'my_cookie',
  value: 'cookie_value'
});

Manifest v2 vs v3 Key Differences

Feature Manifest v2 Manifest v3
Background Persistent page (background.html) Service worker (background.js)
Script execution chrome.tabs.executeScript() chrome.scripting.executeScript()
Host permissions permissions array Separate host_permissions
Web requests chrome.webRequest (blocking) chrome.declarativeNetRequest
CSP Allows unsafe-eval Stricter CSP, no eval
Remote code Allowed with CSP Completely forbidden

Migration: v2 extensions must migrate by June 2024 (deprecated). Focus on v3 for new projects.


Publishing

Chrome Web Store

  1. Create developer account ($5 one-time fee)
  2. Prepare store listing (description, screenshots, icons)
  3. Zip extension directory
  4. Upload to Chrome Web Store Developer Dashboard
  5. Fill out listing details
  6. Submit for review (1-3 days typically)

Privacy Requirements

  • Declare data usage in manifest and store listing
  • Implement privacy policy if collecting user data
  • Limit permissions to minimum necessary
  • Use HTTPS for external connections

Review Guidelines

  • No malware, spyware, or malicious behavior
  • No cryptocurrency mining
  • No deceptive practices
  • Respect user privacy
  • Single purpose policy (don't combine unrelated features)

Advanced Patterns

Dynamic Content Script Injection

// Only inject when needed, not via manifest
chrome.action.onClicked.addListener(async (tab) => {
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    files: ['content.js']
  });
});

Declarative Content (Show icon only on certain pages)

// background.js
chrome.runtime.onInstalled.addListener(() => {
  chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
    chrome.declarativeContent.onPageChanged.addRules([{
      conditions: [
        new chrome.declarativeContent.PageStateMatcher({
          pageUrl: { hostEquals: 'example.com' }
        })
      ],
      actions: [new chrome.declarativeContent.ShowAction()]
    }]);
  });
});

Web Request Blocking (v3 alternative)

// Use declarativeNetRequest for blocking/modifying requests
// manifest.json
"permissions": ["declarativeNetRequest"],
"host_permissions": ["*://*/*"],
"declarative_net_request": {
  "rule_resources": [{
    "id": "ruleset_1",
    "enabled": true,
    "path": "rules.json"
  }]
}

// rules.json
[{
  "id": 1,
  "priority": 1,
  "action": { "type": "block" },
  "condition": {
    "urlFilter": "example.com/ad/*",
    "resourceTypes": ["script"]
  }
}]

Side Panel (Manifest v3.114+)

// manifest.json
"side_panel": {
  "default_path": "sidepanel.html"
}

// Open programmatically
chrome.sidePanel.open({ windowId: windowId });

Troubleshooting

Content script not running: - Check matches patterns in manifest - Verify page loaded (check run_at) - Check for JavaScript errors in page console - Reload extension after changes

Message not received: - return true for async responses - Check chrome.runtime.lastError - Verify recipient context is running - Tab might have reloaded (content script resets)

Permission denied: - Add permission to manifest - Add host_permissions for network requests - Request optional permissions at runtime if needed

Service worker inactive: - Expected behavior - wakes on events - Use chrome.storage, not global variables - Use chrome.alarms, not timers

CORS errors in content script: - Make requests from background instead - Add host_permissions to manifest


Resources

  • Official docs: https://developer.chrome.com/docs/extensions/
  • Samples: https://github.com/GoogleChrome/chrome-extensions-samples
  • Manifest v3 migration: https://developer.chrome.com/docs/extensions/migrating/
  • API reference: https://developer.chrome.com/docs/extensions/reference/
  • Chrome Web Store: https://chrome.google.com/webstore/devconsole