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 }));
};
Popup Connection Handling
// 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:
-
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' }; -
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' }); -
Restart the dev server (
pnpm dev) and test the flow by inspecting the background Service Worker console atchrome://extensions. -
Handle errors by wrapping
sendMessagecalls in try-catch blocks, as disconnected ports or unloaded background pages can throw exceptions.
Summary
- The popup entry point is declared via
action.default_popupinmanifest.tsand built frompages/popup/src/Popup.tsx - Background scripts receive messages through
chrome.runtime.onMessage.addListener()inchrome-extension/src/background/index.ts - Always return
truefrom background listeners when using asynchronoussendResponseto prevent message port closure - Use one-time messages (
sendMessage) for discrete requests and long-lived ports (connect) for continuous data streams - The shared
storagepackage 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →