DEV Community

ExtensionBooster
ExtensionBooster

Posted on

Chrome Extension Content Scripts: Manifest V3 Guide (2026) | ExtensionBooster

Chrome Extension Content Scripts: Manifest V3 Guide (2026) | ExtensionBooster

Chrome extension development in 2026 has its own set of challenges. After working through this, here's what I learned that actually moves the needle.

The Key Points

  • Chrome Extension Content Scripts: Manifest V3 Guide (2026) | ExtensionBooster You’ve spent two hours debugging why your content script can’t read a page variable
  • You’ve confirmed the variable exists using DevTools
  • You’ve triple-checked the selector
  • Then it hits you: your content script runs in an isolated world

The Details

Chrome Extension Content Scripts: Manifest V3 Guide (2026) | ExtensionBooster You’ve spent two hours debugging why your content script can’t read a page variable. You’ve confirmed the variable exists using DevTools. You’ve triple-checked the selector. Then it hits you: your content script runs in an isolated world. It literally cannot see that variable. The page’s JavaScript and your script are sharing the same DOM but living in completely separate JavaScript contexts. That isolation is not a bug. But knowing when it helps you, and when it fights you, is what separates a working extension from a frustrating one. This guide covers everything you need: how the two execution worlds work, every injection method, the chrome. scripting API, match patterns, and a practical section on calling chrome. downloads from a content script context. Isolated World vs Main World: The Core Concept When your content script runs on a page, Chrome places it in an isolated world by default. Picture two people working at the same desk (the DOM) but wearing noise-canceling headphones. They can both move the same papers (DOM nodes), but they can’t hear each other’s conversations (JavaScript variables and functions). Concretely: Your content script has its own JavaScript heap. It shares document and window. document for DOM manipulation. It cannot read window. data , or any variable the page’s own scripts set. The page cannot call your functions or access your variables. This separation is what lets Chrome extensions work safely on any site. Your extension code won’t accidentally collide with the site’s libraries, and a malicious site can’t hijack your extension’s logic. Here’s the comparison you’ll want to bookmark: Isolated World (default) Main World ( world: 'MAIN' ) JS context Separate from page Shared with page Access page variables No Yes Page can access your code No Yes chrome. * APIs available chrome. dom Almost none Messaging to service worker Yes No (must use custom events or postMessage) Stable since Always Chrome 111 Use when Most cases Interacting with page JS (e. , SPA state, third-party SDKs) Gotcha: When you switch to world: 'MAIN' , you gain access to page JS but lose access to most Chrome APIs. You can’t call chrome. ) from a main-world script. Use a coordinating isolated-world script that listens for window. postMessage events from your main-world script. Static Injection: Declaring Scripts in manifest. json The simplest way to inject a content script is a static declaration in your manifest. Chrome reads this at install time and injects automatically on matching pages. { "manifest_version" : 3 , "name" : "My Extension" , "version" : "1. 0" , "content_scripts" : [ { "matches" : [ "https://. com/" ], "js" : [ "content. js" ], "css" : [ "content. css" ], "run_at" : "document_idle" , "world" : "ISOLATED" , "all_frames" : false } ] } The key fields: matches : Array of match patterns . Controls which URLs get your script. ":///" covers all HTTP/HTTPS; "" adds file:// and more. Broad patterns trigger extra scrutiny in Chrome Web Store review. js : Array of script files, injected in order. css : Stylesheets injected before the DOM is ready (before document_start ). run_at : When to inject. "document_idle" (default) fires after the page’s DOMContentLoaded and a short grace period. "document_start" fires before any DOM is parsed. "document_end" fires after DOM is ready but before sub-resources (images, scripts) finish loading. world : "ISOLATED" (default) or "MAIN" . all_frames : Default false . Set true to inject into iframes too. Static injection is ideal for extensions that always need to run on specific sites. The content script loads automatically; no user action required. Dynamic Injection: chrome. scripting Sometimes you don’t want a script running on every page load. Maybe you inject on demand when the user clicks the extension icon, or conditionally based on page content. You’ll need these permissions in your manifest: { "permissions" : [ "scripting" ], "host_permissions" : [ "https://. com/" ] } Or use "activeTab" instead of broad host permissions for click-triggered injection. activeTab grants temporary access to the currently active tab when the user invokes your extension. One-off injection with executeScript // From your service worker or popup async function injectOnCurrentTab () { const [ tab ] = await chrome. query ({ active: true , currentWindow: true }); await chrome. executeScript ({ target: { tabId: tab. id }, files: [ "content. js" ], world: "ISOLATED" // or "MAIN" }); } You can also inject a function directly instead of a file: await chrome. executeScript ({ target: { tabId: tab. id }, func : () => { document. background = "red" ; } }); Note: the injected function runs in an isolated context. Variables from the surrounding service worker closure are not available inside func . Pass data via the args parameter if needed. Persistent registration with registerContentScripts For scripts that should persist across browser restarts (similar to static injection but set up at runtime): await chrome. registerContentScripts ([{ id: "my-script" , matches: [ "https://. com/*" ], js: [ "content. js" ], runAt: "document_idle" , world: "ISOLATED" }]); You can update or unregister these later with chrome. updateContentScripts and chrome. unregisterContentScripts . scripting reference for all options. Which Chrome APIs Can Content Scripts Actually Use. This trips up a lot of developers. Content scripts are not full extension pages. They run in a limited environment with access to only a subset of Chrome APIs: chrome. runtime (messaging, getURL , getManifest , id ) chrome. storage ( local , sync , session ) chrome. i18n (getMessage, etc. dom (utility for accessing the page’s ShadowRoot ) That’s it for direct API access. Everything else, including chrome. notifications , and most other APIs, requires you to message the background service worker. Content scripts are exposed to potentially hostile page environments. Limiting their API surface reduces what an XSS attack can do if it somehow injects code into your content script context. Messaging Between Content Script and Service Worker The standard pattern for calling any restricted Chrome API from a content script: // content. js (content script side) async function requestDownload ( url , filename ) { const response = await chrome. sendMessage ({ type: "DOWNLOAD_FILE" , payload: { url, filename } }); return response; } // Call it requestDownload ( "https://example. pdf" ); // service-worker. js (background side) chrome.

This is a condensed version of a deeper guide. The full article covers additional context and examples.


Worth Bookmarking

If you're working with Chrome extensions, you'll eventually need tools for:

  • Generating icons at multiple sizes
  • Creating store screenshots that look professional
  • Converting MV2 extensions to MV3
  • Analyzing bundle size

ExtensionBooster has free versions of all of these. I keep it bookmarked for every extension project.


Top comments (0)