What Are Content Scripts and Background Scripts in Chrome Extensions?

Learn how content scripts and background service workers work in Chrome extensions, including MV3 contexts, messaging, permissions, and debugging.

Share

Close DevTools, and suddenly your Chrome extension breaks. If this sounds familiar, you just collided with Chrome's multi-context architecture. Chrome extensions do not run in a single JavaScript environment; they operate across strictly guarded privilege zones.

A content script runs directly inside a webpage to read or modify the DOM, but operates with limited browser API access. A background script (service worker) handles extension-wide logic and privileged browser APIs behind the scenes, but cannot touch the page DOM directly. They communicate securely via Chrome's messaging system.

Understanding where your code lives dictates what APIs you can use, how your data persists, and why certain scripts fail.

TL;DR:

  • Content scripts touch the webpage DOM.
  • Background scripts (service workers) control extension logic and browser events.
  • Messaging connects the two environments.

The Mental Model: Chrome Execution Contexts

Chrome splits extension code into different execution contexts for security, stability, and performance.

Your content script is a field agent deployed into a low-trust environment (the webpage). It can see the page but has limited authority. Your background script is mission control. It has high privileges and broader API access but stays off the field.

The 5 Execution Contexts

  1. Page JavaScript: The host website's own code.
  2. Content script in ISOLATED world: Extension code touching the page DOM, separated from the page's JS scope.
  3. Content script in MAIN world: Extension code deliberately mixed into the page's JS scope.
  4. Background script (MV3 service worker): The invisible event handler running extension-wide logic.
  5. Extension page UI / Offscreen document: Popups, side panels, or hidden helper pages.

Do I need both a content script and a background script?

No. You need a content script only if your extension must inspect or modify a webpage. You need a service worker only if your extension must react to browser events, use higher-privilege Chrome APIs, or coordinate extension-wide state. Many simple extensions use just one context.

Key Takeaway: Pick execution contexts based strictly on technical responsibility, not habit.

What Is a Content Script?

A content script is code your extension injects into matching webpages. It can read the DOM, update the page, use runtime messaging, and access storage, but it cannot call most Chrome APIs directly.

Capabilities and Limitations

  • Can do: Modify the page DOM, inject CSS, read extension assets (chrome.runtime.getURL()), and use chrome.storage.
  • Cannot do: Call privileged APIs (like chrome.tabs or chrome.action), hold API keys securely, or automatically access variables defined by the host page's JavaScript.

The Isolated World

An isolated world means your content script shares the page's DOM but not the page's JavaScript scope. You can change page elements, but page variables and functions are invisible to your script unless you inject into the MAIN world. Injecting into MAIN is risky because malicious host scripts can interfere with your injected code.

Declaring a Content Script Chrome Extension

Here is a standard Manifest V3 content_scripts declaration:

{
  "content_scripts": [
    {
      "matches": ["https://*.example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle",
      "all_frames": false
    }
  ]
}
  • matches: Dictates which URLs trigger the injection.
  • run_at: Controls timing. Use document_idle (post-load) for performance, document_end for DOM-ready execution, or document_start to run before the DOM or CSS loads.
  • all_frames: Set to true if the script must run inside nested iframes instead of just the top-level window.

Key Takeaway: Content scripts are for page-facing work. Keep them thin and message the service worker for privileged actions.

What Is a Background Script (Manifest V3 Service Worker)?

In Manifest V3, the background script is an extension service worker. It reacts to browser-level events (installs, clicks, alarms, messages) and coordinates cross-context logic.

Declare it under "background": { "service_worker": "background.js" } in your manifest. Do not use navigator.serviceWorker.register().

Lifecycle Rules

Service workers are ephemeral. They sleep when idle and wake on new events.

  • Idle termination occurs after 30 seconds of inactivity.
  • A single request or event taking longer than 5 minutes to process, or a fetch() response taking longer than 30 seconds to arrive, will also trigger termination.
  • Global variables disappear the moment the worker shuts down.

Because of this ephemeral lifecycle, you must read from and write to chrome.storage.local or chrome.storage.session to guarantee continuity. Additionally, all event listeners (like chrome.runtime.onMessage.addListener()) must live at the top level of your script, never nested inside async functions.

Real-World Example: SDK Initialization

If you integrate a monetization or analytics SDK, the background script handles the core setup. For example, Mellowtel—an open-source SDK allowing developers to monetize extensions through opt-in bandwidth sharing—initializes securely in the service worker to handle network activity and user consent flows. Only non-privileged data collection drops into the lower-trust content script environment.

Key Takeaway: Service workers are event-driven. Extension-wide logic, SDKs, and secret handling belong here, but state must survive sleep cycles via Chrome Storage.

Content Script vs Background Script Comparison

Feature Content Script Background Script (Service Worker)
Runs in Webpage context (ISOLATED world) Background extension process
DOM Access Yes No
Chrome API Access Limited (runtime, storage, etc.) Broad (tabs, alarms, scripting, etc.)
Lifecycle Bound to the tab/page load Wakes on events, sleeps after 30s
Debugging Location Page DevTools Console chrome://extensions "Inspect views" link
State Persistence Resets on page reload Resets on worker sleep (use storage)

Communication Between Contexts

Content scripts and background scripts bridge the gap using Chrome's messaging APIs.

Content Script → Background Script:

Use chrome.runtime.sendMessage() to pass data out of the page and into the extension.

Background Script → Content Script:

Use chrome.tabs.sendMessage(tabId, ...) because the service worker must specify exactly which tab receives the payload.

Async Replies Require return true

If your service worker receives a message, fetches data asynchronously, and tries to sendResponse afterward, the messaging channel will prematurely close. You must include return true inside the listener to keep the channel open.

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  fetch("https://api.example.com").then(res => res.json()).then(data => {
    sendResponse({ result: data });
  });
  return true; // Keeps channel open for async response
});

Key Takeaway: Always treat messages originating from the content script as untrusted. Validate inputs in your service worker before executing privileged actions.

How to Inject Scripts: chrome.scripting.executeScript

You can inject page code three ways: statically in the manifest, dynamically via registerContentScripts(), or on demand using the scripting API.

Using chrome.scripting.executeScript() is the best approach for user-initiated actions, like clicking an extension icon. Instead of bloating every page load with unused code, you inject code precisely when required.

If you are looking for chrome scripting executescript examples, here is how you pass arguments programmatically:

chrome.scripting.executeScript({
  target: { tabId: activeTab.id, allFrames: false },
  func: (color) => { document.body.style.backgroundColor = color; },
  args: ["#ff0000"]
});

Understanding the Target and Return Values

The chrome scripting executescript target object is mandatory and requires a tabId. You can use allFrames: true to hit the top frame and all iframes, or target specific frames using an array of frameIds.

When execution finishes, it yields an array of InjectionResult objects—one per targeted frame.

(Note: If you see the error chrome.tabs.executeScript is not a function, you are likely using a deprecated Manifest V2 snippet. In MV3, this moved to the chrome.scripting namespace).

Key Takeaway: Static injection = always-on. chrome.scripting.executeScript = on-demand.

Performance and Security Rules

Content scripts injected across <all_urls> at document_start actively delay website rendering. Heavy synchronous operations here will degrade Core Web Vitals. Use document_idle unless your feature demands strict pre-paint execution to prevent UI flicker.

Security-wise, content scripts are the least trusted extension runtime. An isolated world separates JS scope, but it does not grant full security against a compromised host page. Never send API keys, authentication tokens, or browsing history directly to a content script.

Beware of AI-Generated Extensions: A recent ChromeSecBench study found that AI-generated Chrome extensions exhibit vulnerability rates between 18% and 50%. AI coding assistants routinely fail to respect Chrome's privilege boundaries, frequently leaking sensitive cookies or tokens into untrusted webpage contexts. Always manually audit messaging pipelines.

Key Takeaway: Every injected kilobyte impacts rendering. Keep secrets entirely inside the service worker.

Debugging and Common Errors

Debug content scripts in the target webpage's DevTools console. Debug the service worker from the chrome://extensions dashboard by clicking the "service worker" link.

The DevTools Trap: Inspecting the service worker keeps it artificially awake indefinitely. This hides 30-second suspension bugs, making your code look stable until real users install it. Test your extension with the service worker DevTools closed to catch real restart behavior.

Troubleshooting Matrix

Symptom Likely Cause Fastest Fix
chrome extension content script not loading URL mismatch, or tab opened before extension loaded Check matches regex, reload the extension, then reload the webpage.
chrome.tabs is undefined Calling browser APIs in the page context Send a message to the service worker to handle tab actions.
Error in web-client-content-script.js Third-party extension (like LastPass) injecting code into your app If you see Uncaught Error: Access to storage is not allowed from this context, a third-party content script is improperly attempting to hit chrome.storage.session.
Could not establish connection Content script not injected yet, or missing listener Ensure the content script is loaded before sending a message.

Key Takeaway: Wrong console = wrong conclusion. If you modify a content script, you must refresh the host webpage, not just the extension package.

Complete MV3 Chrome Extension Content Script Example

Here is a minimal chrome extension content script example using three files to demonstrate context separation and messaging.

1. manifest.json

{
  "manifest_version": 3,
  "name": "Context Demo",
  "version": "1.0",
  "permissions": ["storage"],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

2. content.js

const pageTitle = document.title;
chrome.runtime.sendMessage({ action: "log_title", title: pageTitle }, (response) => {
  console.log("Service worker responded:", response.status);
});

3. background.js

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.action === "log_title") {
    console.log("Tab title received:", message.title);
    sendResponse({ status: "Success!" });
  }
});

Load this folder unpacked via chrome://extensions. Check the page console for the success log, and inspect the service worker to see the title logged.

FAQ

Can a content script access chrome.tabs?

No. Content scripts can directly use the DOM, chrome.runtime, and chrome.storage, but not chrome.tabs. If page-side code needs tab data, send a message to the service worker and let it call the tabs API.

How do I use a chrome extension content script import module?

To use an ES module inside a content script, you cannot simply use type="module" in the manifest's content_scripts array. Instead, inject a dynamic import wrapper via your standard content script, or bundle your code using tools like Webpack or Rollup before loading it into the browser.

What does all_frames do?

all_frames tells Chrome to inject the script into every matching frame in a tab, not just the top frame. Use it when your feature needs to modify content inside nested iframes.

When should I use the MAIN world instead of ISOLATED?

Only when you explicitly require access to the host-page's custom JavaScript variables or functions. Doing so exposes your extension code to interference from the webpage.

Bottom Line

Chrome extension architecture relies on three unbreakable rules. First, content scripts touch pages but lack browser authority. Second, background service workers handle privileged logic but cannot touch the DOM. Third, messaging securely connects the two.

By treating these contexts as distinct privilege zones, you eliminate most injection and lifecycle bugs. Remember that Manifest V3 architecture is strictly event-driven; your service worker will sleep, so build with storage persistence in mind. Map out your runtime boundaries early, and keep your highest-privilege logic safely tucked away in the background.