Messaging Between Frames in the Same Tab
Coordinate content scripts in a page and its iframes in MV3: all_frames injection, frameId targeting with tabs.sendMessage, relaying through the service worker, webNavigation.getAllFrames and postMessage pitfalls.
Table of Contents
The feature spans frames: the top-level page shows a toolbar, while the content you need — an embedded editor, a payment form, a video player — lives in an iframe from another origin. Your content scripts run in both, injected with all_frames: true, but they cannot simply call each other: each runs in its own frame’s isolated world, the frames may be cross-origin, and window.postMessage between them is visible to the page’s own scripts. The reliable path runs through the extension: content scripts message the service worker, and the worker addresses a specific frame by its frameId. This guide builds that routing. It belongs to message passing architecture.
How frames are addressed
Every frame in a tab has a frameId: 0 for the top-level document, positive integers for iframes. A content script learns its own frame id from sender.frameId in any message it sends to the worker, and the worker can list a tab’s frames with chrome.webNavigation.getAllFrames({ tabId }), which returns each frame’s id, parent id and URL. chrome.tabs.sendMessage(tabId, msg, { frameId }) delivers a message to the content script in exactly one frame; without the frameId option, it goes to every frame’s content script, and the first one to respond wins. chrome.runtime.sendMessage from any frame’s content script reaches the worker, with sender.tab.id and sender.frameId identifying where it came from. Recent Chrome versions add documentId, which identifies a specific document in a frame and becomes invalid when the frame navigates — a safer target for delayed messages.
Step-by-step: frame-aware messaging
1. Inject into the frames you need
1{
2 "content_scripts": [{
3 "matches": ["https://app.example.com/*", "https://editor.example.net/*"],
4 "js": ["content.js"],
5 "all_frames": true,
6 "match_origin_as_fallback": true, // also inject into about:blank / srcdoc frames from matching origins
7 "run_at": "document_idle"
8 }]
9}
Execution context: the manifest. all_frames injects into every frame whose URL matches; the iframe’s origin must be covered by matches and by host permissions. match_origin_as_fallback (Chrome 99+) handles frames with about:blank, blob: or data: URLs by matching their creator’s origin. The same script runs in every frame, so it should branch on whether it is the top frame (window.top === window).
2. Announce each frame to the worker
1// content.js
2const role = window.top === window ? "top" : detectRole(); // e.g. "editor", "player"
3chrome.runtime.sendMessage({ type: "frame:hello", role });
1// sw.js
2const framesByTab = new Map(); // tabId -> Map(role -> frameId)
3
4chrome.runtime.onMessage.addListener((msg, sender) => {
5 if (msg?.type !== "frame:hello" || !sender.tab) return;
6 const roles = framesByTab.get(sender.tab.id) ?? new Map();
7 roles.set(msg.role, { frameId: sender.frameId, documentId: sender.documentId });
8 framesByTab.set(sender.tab.id, roles);
9});
Execution context: content scripts in each frame, and the service worker. The worker learns which frame plays which role in each tab from the sender fields the browser fills in — content scripts cannot forge them. The map is in memory; because a worker restart loses it, persist it in chrome.storage.session or rebuild it from webNavigation.getAllFrames (step 4).
3. Route messages between frames
1// sw.js
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 if (msg?.type !== "frame:relay" || !sender.tab) return;
4 const target = framesByTab.get(sender.tab.id)?.get(msg.to);
5 if (!target) { sendResponse({ ok: false, error: "no-frame" }); return; }
6 chrome.tabs.sendMessage(sender.tab.id, { type: msg.payloadType, payload: msg.payload, from: msg.fromRole },
7 target.documentId ? { documentId: target.documentId } : { frameId: target.frameId })
8 .then((reply) => sendResponse({ ok: true, reply }), (err) => sendResponse({ ok: false, error: err.message }));
9 return true;
10});
Execution context: the service worker. Messages name a role (“editor”, “top”), not a frame id, so content scripts do not need to know frame numbers. Targeting by documentId where available means a message never lands in a frame that has since navigated to a different document. Only relay a fixed set of payload types; a generic relay lets a script in one frame drive another frame’s content script arbitrarily.
4. Rebuild the frame map from webNavigation
1export async function refreshFrames(tabId) {
2 const frames = await chrome.webNavigation.getAllFrames({ tabId });
3 return frames.map(({ frameId, parentFrameId, url, documentId }) => ({ frameId, parentFrameId, url, documentId }));
4}
Execution context: the service worker, with the webNavigation permission (install warning: “Read your browsing history”). After a worker restart, or to find a frame whose content script has not said hello yet, list the tab’s frames and match by URL. If you prefer to avoid the permission, have content scripts re-announce themselves when the worker asks via a broadcast tabs.sendMessage without frameId.
5. Clean up when frames go away
1chrome.tabs.onRemoved.addListener((tabId) => framesByTab.delete(tabId));
2chrome.webNavigation?.onCommitted.addListener(({ tabId, frameId }) => {
3 if (frameId === 0) framesByTab.delete(tabId); // top-level navigation resets the tab
4});
Execution context: the service worker. Stale frame entries cause messages to go to documents that no longer exist (an error) or, with bare frame ids, to a different document that reused the id. Clearing on tab close and top-level navigation keeps the map honest; per-frame removal can use the content scripts’ pagehide to send a goodbye.
6. If you must use postMessage, verify the source
1// Same-origin frames you control, where routing through the worker is overkill
2window.addEventListener("message", (e) => {
3 if (e.origin !== "https://editor.example.net") return;
4 if (e.data?.channel !== "readable-ext" || !e.data.nonce || e.data.nonce !== sharedNonce) return;
5 handle(e.data);
6});
Execution context: a content script. window.postMessage is visible to every script in the receiving window, including the page’s, and a page script can send messages that look like yours. Check origin, use a channel name, and share a nonce through the worker so page scripts cannot forge messages. Even then, never put secrets in postMessage payloads. See bridging data between main world and isolated world.
7. Handle frames without your content script
Some frames will never answer: cross-origin frames your host permissions do not cover, sandboxed frames, PDFs and browser-internal pages. Treat “no-frame” as a normal outcome in the UI — “Open the editor to see the word count” — rather than an error to retry.
Common mistakes
tabs.sendMessagewithoutframeId. Every frame receives it and the first responder wins.- Hard-coded frame ids. Ids differ per page load; discover them.
- Frame maps only in memory. A worker restart loses them; persist or rebuild.
- Trusting
postMessagedata. Page scripts can forge it. - Generic relays. They let one frame’s content script control another arbitrarily.
Cross-browser variation
- Chrome / Edge:
frameIdanddocumentIdtargeting,match_origin_as_fallback, andwebNavigation.getAllFrames. - Firefox:
frameIdtargeting andgetAllFrameswork;documentIdsupport arrived later — feature-detect it.match_about_blankcovers some of the same cases. - Safari: supports
frameIdtargeting;webNavigationsupport is partial, so prefer the hello-announcement approach.
Verification
- Load a page with a cross-origin iframe your extension matches and confirm both frames send hello with distinct
frameIds. - Trigger a relay from the iframe and confirm only the top frame receives it.
- Navigate the iframe to another document and confirm stale
documentIdtargeting fails cleanly rather than reaching the new document. - Reload the extension and confirm the frame map rebuilds.
FAQ
Can a content script in the top frame read the iframe’s DOM?
Only for same-origin iframes. For cross-origin iframes, inject a content script into the iframe and message it.
Do frames in different tabs ever collide?
Frame ids are per tab; always key by tab id first.
How do I message every frame at once?
Omit frameId and handle responses in each frame, or loop over getAllFrames and send to each.
Related
- Injecting content scripts into dynamic iframes — getting scripts into frames first.
- Injecting into iframes and all frames — programmatic injection per frame.
- Broadcasting messages to all tabs — the multi-tab counterpart.
- Message passing architecture — the parent topic.