Skip to content

Chrome extension content script not working: matches, run_at, and SPA navigations

7 min readModerok team

Why a Chrome extension content script is not working: the manifest matches and run_at fix, patterns that match nothing, and SPA route changes.

If your Chrome extension content script is not working, start in manifest.json. matches is mandatory, every pattern needs a path component, and a content script edit only takes effect after you reload the extension and the page it runs on. A block that injects looks like this:

{
  "manifest_version": 3,
  "name": "My Extension",
  "version": "1.0.0",
  "content_scripts": [
    {
      "matches": ["https://app.example.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

Chrome's content scripts documentation states the first rule outright: "All auto-run content scripts must specify match patterns." Everything below is the list of reasons a block that looks correct still runs nothing.

Reload the extension, then reload the page

Before debugging anything structural, rule out stale code. The Hello World tutorial's reload table gives content scripts their own row, answering "Yes (plus the host page)". The extension reload re-reads the manifest and the script file; the page reload gives the browser a fresh document to inject into. Reload only the extension and open tabs keep the old script; refresh only the page and it re-runs the old file.

This is also why a fresh install does nothing on the tabs you had open while installing it: those documents passed their injection points before the extension existed. Reload them, or inject into them from an onInstalled handler with chrome.scripting.executeScript. That route needs two manifest keys, not one: the chrome.scripting reference says to "declare the "scripting" permission in the manifest plus the host permissions for the pages to inject scripts into." Chrome's guidance for that case is short: "Use programmatic injection for content scripts that need to run in response to events or on specific occasions."

Content script not working? Read the match pattern character by character

Chrome gives the structure as <scheme>://<host>:<port>/<path>, and the path is not optional. MDN's match patterns reference lists the failures as a table, and the two most common entries are plain typos:

  • https://mozilla.org is "Invalid", reason: "No path."
  • *://* is "Invalid", reason: "Empty path: this should be *://*/*."

Two more rules quietly match nothing rather than erroring.

The path is matched against the query string too. MDN is explicit: "The value for the path matches against the string which is the URL path plus the [URL query string]. This includes the ? between the two, if the query string is present in the URL." So a pattern ending /dashboard/ does not match /dashboard/?tab=billing, and MDN lists https://mozilla.org/path/?foo=1 as an unmatched example. Chrome's own page does not spell this rule out, so when in doubt end the pattern with /*.

A # makes the pattern unmatchable. Per MDN, "A match pattern containing # will fail to match with any URL," because the fragment is stripped before matching. For a hash route, match the origin and read location.hash in the script.

Chrome's match patterns page adds two rules of its own: a host wildcard must be the first or only character, followed by a period or a forward slash, and "Chrome doesn't support match patterns for top Level domains (TLD)". So neither https://*example.com/* nor https://example.*/* is a thing.

Note too that exclude_matches and include_globs only narrow what matches already selected. If the pattern is right and Chrome still refuses, the problem is permissions or a restricted URL, not matching. A declarative script just never runs there, while the programmatic equivalent fails with a message you can read: Cannot access contents of url.

run_at: the script ran, your element did not exist

When a console.log at the top of the file fires but document.querySelector returns null, injection is fine and the timing is wrong. The three documented values are not interchangeable:

  • document_start: "Scripts are injected after any files from css, but before any other DOM is constructed or any other script is run."
  • document_end: "Scripts are injected immediately after the DOM is complete, but before subresources like images and frames have loaded."
  • document_idle (the default): "The browser chooses a time to inject scripts between "document_end" and immediately after the window.onload event fires."

Note what document_idle is: a window the browser picks, not a fixed moment, so it is a bad thing to depend on for ordering. And none of the three waits for content a framework renders after load. For an element that appears when a React tree mounts, no run_at value helps; use a MutationObserver on a container that does exist, or a poll with a deadline.

all_frames, and the iframe you forgot

If the element you want lives in an embedded frame, the default excludes it. Chrome's manifest reference: all_frames "Defaults to false, meaning that only the top frame is matched." Set "all_frames": true, and remember each frame gets its own copy of the script, so guard anything that must happen once per tab.

Single-page apps: the script injects once and never again

Every run_at value above is defined relative to loading a document. A single-page app route change calls history.pushState and swaps the view without loading a new document, so there is no second injection point and your declared script never runs again for that route. The script itself is still alive, because the document it was injected into was never replaced. Re-injecting on every route change is therefore the wrong fix: you get two copies.

Tell the existing script instead. The hook is chrome.webNavigation.onHistoryStateUpdated: "Fired when the frame's history was updated to a new URL." All chrome.webNavigation methods and events "require you to declare the "webNavigation" permission in the extension manifest", and the event accepts a filter whose url entries hold "Conditions that the URL being navigated to must satisfy."

A fragment-only change, which is what a hash router does, fires a different event: Chrome documents onReferenceFragmentUpdated as "Fired when the reference fragment of a frame was updated." Register both, or let the content script cover the hash case with a hashchange listener, as below.

{
  "background": { "service_worker": "background.js" },
  "permissions": ["webNavigation"]
}
// background.js
const onRoute = (details) => {
  if (details.frameId !== 0) return;
  chrome.tabs.sendMessage(
    details.tabId,
    { type: "route-changed", url: details.url },
    { frameId: 0 },
  ).catch(() => {
    // No content script in that tab: nothing to update.
  });
};

const filter = { url: [{ hostEquals: "app.example.com" }] };
chrome.webNavigation.onHistoryStateUpdated.addListener(onRoute, filter);
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onRoute, filter);
// content.js
function render() {
  // idempotent: find or create your container, then fill it
}

chrome.runtime.onMessage.addListener((message) => {
  if (message.type === "route-changed") render();
});

window.addEventListener("hashchange", render); // hash routers
render(); // the normal page-load path

Both frameId uses earn their place. On the event, Chrome documents 0 as the tab's content window, where "a positive value indicates navigation in a subframe", so without the check an embedded frame's history change triggers your render. In the send options it does the mirror job, since frameId means "Send a message to a specific frame identified by frameId instead of all frames in the tab": without it, one top-frame route change re-runs render() in every all_frames copy. In the filter, hostEquals "Matches if the host name of the URL is equal to a specified string".

The content script can register runtime.onMessage because it is on the short list Chrome publishes of extension APIs "content scripts can access ... directly", alongside runtime.sendMessage(). Most of the chrome.* surface is not, including the tabs and webNavigation calls above: "Content scripts are unable to access other APIs directly." That is why the navigation listener has to live in the service worker.

What still breaks after the fix

  • Your extension updates while pages are open. The old script keeps running against a dead extension context and every chrome.* call throws. Different error, different fix: Extension context invalidated.
  • chrome.tabs.sendMessage fails with "Receiving end does not exist" on any tab with no injected script, including restricted pages and tabs loaded before install. The promise form rejects, as above; the older callback form reports it on chrome.runtime.lastError.
  • activeTab does not help on install. Host permissions for injection come from the manifest or, per the content scripts page, "temporarily using "activeTab"", and onInstalled has no gesture to trade on.

Firefox differences

  • run_at is defined against document readiness, not a browser-chosen window: MDN maps document_idle to "Corresponds to complete."
  • Empty iframes behave differently: "in Firefox, content scripts won't be injected into empty iframes at "document_start", even if you specify that value in run_at." match_about_blank is supported "in Firefox from version 52".
  • Ports are Chrome-only in the pattern grammar. MDN gives the structure as <scheme>://<host><path> and notes "Firefox doesn't support the inclusion of a port number", so https://localhost:3000/* matches nothing there.
  • Firefox blocks content scripts outright on its own properties, including addons.mozilla.org and accounts.firefox.com, with no manifest key that unlocks them.

Seeing injection failures you cannot reproduce

Locally you reload the page and move on. In production you never see the tab where the pattern missed, the frame that turned out to be an iframe, or the route change that stopped rendering it. Since a content script cannot reach most extension APIs anyway, the shape is a relay: chrome.runtime.sendMessage from the content script, and the analytics call in the service worker. That is the pattern the Moderok docs recommend, and one event fired from render() tells you whether your script still runs on real pages or stopped at the second route.