How to Create a Popup and Implement Background Script Communication in a Chrome Extension

You create a popup by declaring action.default_popup in manifest.ts and building a React component in pages/popup/src/Popup.tsx, then communicate with the background Service Worker using chrome.runtime.sendMessage and onMessage listeners in chrome-extension/src/background/index.ts.

The chrome-extension-boilerplate-react-vite repository provides a modern architecture for building Chrome extensions with React and Vite. This guide covers how to configure the popup entry point, build the UI with shared state management, and implement type-safe message passing between the popup and background script.

Configuring the Popup Entry Point

The boilerplate stores the popup page under pages/popup, with the main React component at pages/popup/src/Popup.tsx. Vite compiles this into dist/popup/index.html during the build process.

To make Chrome recognize this as your extension's popup, declare it in the manifest:

// chrome-extension/manifest.ts
action: {
  default_popup: 'popup/index.html',   // Entry point for toolbar click
  default_icon: 'icon-34.png',
},

This configuration tells Chrome to open popup/index.html whenever a user clicks the extension icon in the toolbar. The file path is relative to the dist folder, which mirrors your source structure after the Vite build.

Building the Popup UI with React

The Popup.tsx component functions like any standard React application, with full access to the chrome.runtime and chrome.tabs APIs. It typically imports shared storage from @extension/storage to maintain state synchronization across extension pages.

Key implementation details from the source:

  • Use useStorage(exampleThemeStorage) to read and write theme state that persists across popup sessions
  • Call chrome.tabs.create() to open external links in new browser tabs
  • Execute chrome.scripting.executeScript() to inject content scripts into the active tab
// pages/popup/src/Popup.tsx
const Popup = () => {
  const { isLight } = useStorage(exampleThemeStorage);
  
  const injectContentScript = async () => {
    const [tab] = await chrome.tabs.query({ currentWindow: true, active: true });
    await chrome.scripting.executeScript({
      target: { tabId: tab.id! },
      files: ['/content-runtime/example.iife.js'],
    });
  };

  return (
    <div className={cn('App', isLight ? 'bg-slate-50' : 'bg-gray-800')}>
      <button onClick={injectContentScript}>
        Inject Script
      </button>
      <ToggleButton>{t('toggleTheme')}</ToggleButton>
    </div>
  );
};

export default withErrorBoundary(withSuspense(Popup, <LoadingSpinner />), ErrorDisplay);

The component uses chrome.runtime.getURL() to resolve static assets like logos and SVGs, ensuring they load correctly from the bundled extension package.

Implementing Popup-to-Background Communication

While the popup can access many Chrome APIs directly, privileged operations (such as accessing chrome.storage.session or batch processing) should be delegated to the background Service Worker. The boilerplate sets up the background script at chrome-extension/src/background/index.ts.

Setting Up the Background Listener

Register message handlers in the background script to process requests from the popup:

// chrome-extension/src/background/index.ts
import 'webextension-polyfill';
import { exampleThemeStorage } from '@extension/storage';

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg.type === 'GET_THEME') {
    exampleThemeStorage.get().then(state => sendResponse({ theme: state.theme }));
    return true; // Required for async sendResponse
  }

  if (msg.type === 'TOGGLE_THEME') {
    exampleThemeStorage.toggle().then(() => sendResponse({ ok: true }));
    return true;
  }

  return false;
});

Critical: Return true from the listener when calling sendResponse asynchronously. This keeps the message channel open until the Promise resolves.

Sending Messages from the Popup

Use chrome.runtime.sendMessage() in the popup to dispatch requests to the background:

// Inside Popup.tsx or a helper hook
const requestTheme = async () => {
  const response = await chrome.runtime.sendMessage({ type: 'GET_THEME' });
  console.log('Current theme:', response.theme);
};

const toggleThemeViaBackground = async () => {
  await chrome.runtime.sendMessage({ type: 'TOGGLE_THEME' });
};

This pattern decouples your UI from implementation details. The background can handle complex logic, storage migrations, or API calls while the popup remains lightweight and focused on presentation.

Using Long-Lived Ports for Continuous Updates

For real-time data streams (such as live theme updates or progress notifications), establish a persistent connection using chrome.runtime.connect() instead of one-time messages.

Background Port Management

// Add to chrome-extension/src/background/index.ts
const ports = new Set<chrome.runtime.Port>();

chrome.runtime.onConnect.addListener(port => {
  ports.add(port);
  port.onDisconnect.addListener(() => ports.delete(port));
});

// Broadcast updates to all connected popups
const broadcastThemeChange = (theme: string) => {
  ports.forEach(port => port.postMessage({ type: 'THEME_CHANGED', theme }));
};
// Inside Popup.tsx
useEffect(() => {
  const port = chrome.runtime.connect();
  
  port.onMessage.addListener(msg => {
    if (msg.type === 'THEME_CHANGED') {
      setTheme(msg.theme);
    }
  });
  
  return () => port.disconnect();
}, []);

Long-lived ports remain open as long as the popup is active, allowing the background to push updates without polling. For simple request-response patterns, stick with sendMessage.

Step-by-Step Implementation

Follow these steps to add popup-to-background communication to your extension:

  1. Define message types in a shared package for TypeScript safety:

    // packages/shared/lib/types/message.ts
    export type PopupToBackgroundMsg =
      | { type: 'GET_THEME' }
      | { type: 'TOGGLE_THEME' };
  2. Import types in the popup when sending messages:

    import type { PopupToBackgroundMsg } from '@extension/shared/lib/types/message';
    
    await chrome.runtime.sendMessage<PopupToBackgroundMsg>({ type: 'GET_THEME' });
  3. Restart the dev server (pnpm dev) and test the flow by inspecting the background Service Worker console at chrome://extensions.

  4. Handle errors by wrapping sendMessage calls in try-catch blocks, as disconnected ports or unloaded background pages can throw exceptions.

Summary

  • The popup entry point is declared via action.default_popup in manifest.ts and built from pages/popup/src/Popup.tsx
  • Background scripts receive messages through chrome.runtime.onMessage.addListener() in chrome-extension/src/background/index.ts
  • Always return true from background listeners when using asynchronous sendResponse to prevent message port closure
  • Use one-time messages (sendMessage) for discrete requests and long-lived ports (connect) for continuous data streams
  • The shared storage package provides type-safe state management, but message passing offers immediate cross-context updates without storage events

Frequently Asked Questions

Can the popup communicate directly with content scripts?

No, the popup cannot directly message content scripts. According to the Chrome extension architecture, you must route communication through the background script: the popup sends a message to the background (chrome.runtime.sendMessage), and the background forwards it to the content script (chrome.tabs.sendMessage). This triangulation ensures proper context isolation and security boundaries.

Why does the background listener need to return true?

The listener must return true when you plan to send a response asynchronously (such as after awaiting storage operations or API calls). Returning true keeps the internal message channel open until sendResponse is invoked. If you return undefined or false, Chrome immediately closes the port, causing "The message port closed before a response was received" errors in the popup.

How do I handle type safety across popup and background messages?

Define a TypeScript union type for all valid message shapes in the shared package (packages/shared/lib/types/message.ts), then use generic type parameters when calling sendMessage<T>(). While this won't enforce runtime safety, it provides compile-time checking that both sender and receiver agree on the message schema, preventing typo-related bugs during development.

What's the difference between chrome.runtime.sendMessage and chrome.runtime.connect?

sendMessage establishes a short-lived connection for one-off requests and responses, automatically closing after the background calls sendResponse. connect creates a long-lived port that remains open until explicitly disconnected (port.disconnect()), allowing multiple bidirectional messages. Use sendMessage for buttons and toggles; use connect for live streaming data or keeping UI state synchronized with background processes.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →