Injecting into Already Open Tabs After Install
Make an MV3 extension work in tabs that were open before install or update: why manifest content scripts are not injected retroactively, re-injecting on onInstalled, idempotent scripts, permissions and limits.
Table of Contents
A user installs the extension, switches back to the tab they were reading, clicks the toolbar button — and nothing works until they reload the page. Or after an update, every open tab shows “Extension context invalidated” errors in the console and the extension’s in-page UI stops responding. Chrome injects manifest-declared content scripts only when a page loads. Tabs that were already open at install or update time do not get the new scripts until they navigate. The fix is to inject into existing tabs yourself on onInstalled, with scripts written to tolerate being injected into a page that may already have an old copy. This guide shows how. It belongs to scripting API and dynamic injection.
Why open tabs miss the content script
Content scripts declared in the manifest are attached to navigations: when a document starts loading and its URL matches, the browser injects the script at the declared run_at stage. Installation is not a navigation, so existing documents are untouched. On update, the old version’s content scripts keep running in open tabs but are disconnected from the extension — their chrome.runtime calls fail — and the new version’s scripts are not injected until reload. Chrome does not do retroactive injection automatically because injecting into arbitrary existing pages could break them; it leaves the decision to the extension. Firefox behaves differently and injects declared content scripts into matching tabs on install, which is one reason cross-browser code must be idempotent.
Step-by-step: inject on install and update
1. Find matching tabs on onInstalled
1// sw.js
2chrome.runtime.onInstalled.addListener(async ({ reason }) => {
3 if (reason !== "install" && reason !== "update") return;
4 const cs = chrome.runtime.getManifest().content_scripts ?? [];
5 for (const entry of cs) {
6 const tabs = await chrome.tabs.query({ url: entry.matches });
7 for (const tab of tabs) injectEntry(tab.id, entry).catch(() => {});
8 }
9});
Execution context: the service worker, top-level listener. tabs.query with url patterns returns only tabs the extension has host access to (or all tabs with URLs if it has the tabs permission); the same match patterns as the manifest entry find exactly the tabs that would have received the script on load. Errors per tab are swallowed because some tabs will be discarded, restricted or closing — see handling injection errors on restricted pages.
2. Mirror the manifest entry in the injection
1async function injectEntry(tabId, entry) {
2 const target = { tabId, allFrames: Boolean(entry.all_frames) };
3 if (entry.css?.length) await chrome.scripting.insertCSS({ target, files: entry.css });
4 if (entry.js?.length) {
5 await chrome.scripting.executeScript({
6 target, files: entry.js,
7 world: entry.world ?? "ISOLATED",
8 injectImmediately: entry.run_at === "document_start",
9 });
10 }
11}
Execution context: the service worker. Deriving the injection from the manifest’s own content_scripts entries keeps the two in sync — add a script to the manifest and existing tabs get it too. The page is already loaded, so run_at mostly does not apply; scripts written for document_start must cope with running after the DOM exists.
3. Make the content script idempotent and version-aware
1// content.js — top of file
2(() => {
3 const VERSION = chrome.runtime.getManifest().version;
4 const MARK = "__readable_cs__";
5 const prev = globalThis[MARK];
6 if (prev?.version === VERSION) return; // already running this version
7 prev?.teardown?.(); // old version in this isolated world
8 document.documentElement.dispatchEvent(new CustomEvent("readable:teardown")); // old version, other world
9 globalThis[MARK] = { version: VERSION, teardown: start() };
10})();
Execution context: the content script in the isolated world. Injecting the same script twice — once by re-injection, once by a later navigation, or by Firefox’s own install-time injection — must not create duplicate listeners or UI. A global marker in the isolated world detects a second run of the same version. After an update, the old version ran in a different isolated world instance and cannot be reached through globals, so a DOM event asks it to tear itself down, as described in removing injected UI cleanly.
4. Throttle injection across many tabs
1async function injectAll(tabs, entry, concurrency = 4) {
2 const queue = [...tabs];
3 await Promise.all(Array.from({ length: concurrency }, async () => {
4 while (queue.length) {
5 const tab = queue.shift();
6 if (tab.discarded || tab.status === "unloaded") continue; // don't wake discarded tabs
7 await injectEntry(tab.id, entry).catch(() => {});
8 }
9 }));
10}
Execution context: the service worker. Users with hundreds of tabs would otherwise see a CPU spike as every tab receives a script at once. A small concurrency limit spreads the work. Discarded tabs have no document; injecting into them fails or forces a reload, so skip them — they will get the manifest script normally when the user returns and they reload.
5. Prioritise the active tab
1const [active] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
2const ordered = [active, ...tabs.filter((t) => t.id !== active?.id)].filter(Boolean);
Execution context: the service worker. Inject into the tab the user is looking at first, so the extension works there within a moment of install, and handle the rest afterwards.
6. Re-register dynamic content scripts
1chrome.runtime.onInstalled.addListener(async () => {
2 const registered = await chrome.scripting.getRegisteredContentScripts();
3 for (const cs of registered) {
4 const tabs = await chrome.tabs.query({ url: cs.matches });
5 await injectAll(tabs, { js: cs.js, css: cs.css, all_frames: cs.allFrames, world: cs.world });
6 }
7});
Execution context: the service worker. Scripts registered with registerContentScripts and persistAcrossSessions: true survive updates, but, like manifest scripts, they are not injected retroactively. Apply the same treatment.
7. Tell the user when a reload is still needed
Some pages cannot be fixed by injection: single-page apps whose state your old script already modified, or pages where your script must run at document_start to work. In those cases, show a one-time hint in the popup — “Reload this tab to finish setting up Readable” — rather than injecting into a page that will behave unpredictably.
Common mistakes
- Assuming install injects scripts. Chrome does not; open tabs stay without them.
- Non-idempotent content scripts. Double injection duplicates listeners and UI.
- Injecting into discarded tabs. It fails or wakes them; skip them.
- Injecting into hundreds of tabs at once. Throttle and prioritise the active tab.
- Separate lists for manifest and re-injection. Derive one from the other.
Cross-browser variation
- Chrome / Edge: no retroactive injection; old scripts keep running orphaned after updates.
- Firefox: injects declared content scripts into matching existing tabs on install and enable; idempotency guards are essential so your own re-injection does not double up.
- Safari: behaviour varies by version; Safari’s per-site permission prompts may mean no tabs are accessible at install time.
Verification
- Open several matching tabs, install the extension, and confirm the feature works in each without reloading.
- Update the extension (bump the version and reload) and confirm exactly one instance runs per tab and the old UI is gone.
- Discard a tab from
chrome://discards, install, and confirm it is skipped and works after the user returns. - In Firefox, confirm no double initialisation occurs.
FAQ
Does this need the tabs permission?
No. tabs.query({ url }) returns tabs matching your host permissions without tabs.
Should I reload tabs instead?
Reloading discards the user’s state — form input, scroll position, media playback. Inject rather than reload, except where a reload is truly required.
What about tabs in incognito?
They are included only if the extension is allowed in incognito.
Does re-enabling a disabled extension need the same treatment?
Yes. Re-enabling does not fire onInstalled — your worker simply starts again, and open tabs still lack your content script. Run the same re-injection when the worker starts and finds that a stored “last injected” marker is missing or older than the current version, and record the marker once injection completes.
Related
- Cleaning up orphaned content scripts after reload — the orphan side of updates.
- Registering content scripts at runtime — dynamic registrations.
- Fixing extension context invalidated after an update — what old scripts see.
- Scripting API and dynamic injection — the parent topic.