Picture this: you're an hour into chasing a bug that only shows up in production. The console looks completely clean. No errors, no warnings, nothing unusual. Then you spot it — the context dropdown in DevTools is sitting on "top." The error isn't in your page at all. It's firing inside an <iframe>. You switch contexts. There it is, staring back at you. You've been looking in the wrong room the whole time. Welcome to debugging JavaScript inside iframes — one of the quieter time-sinks in frontend development.
Why iframes are such a pain to debug
Every iframe is its own little universe. Separate window object, separate JavaScript context, separate console stream. Chrome doesn't pipe iframe logs into the top-level console by default — it genuinely can't, because frames are isolated by design. So when something breaks inside a child frame, you either catch it by luck or you miss it completely.
The pain multiplies with how many frames are actually on a page. Storybook renders components inside an iframe. That Intercom bubble in the corner? Iframe. Your embedded Stripe checkout? Also an iframe. A typical app in active development can have four or five frames running at once, each one a separate island of console output that you can't see from the main console.
What Chrome is actually doing under the hood
Here's something that catches a lot of developers off guard: the isolation isn't just a DevTools UI decision. It goes all the way down to V8. Each frame gets its own JavaScript heap, its own global object, and its own prototype chain.
A classic example of why this matters: if you create an Array inside a child iframe and pass it to the parent page, arr instanceof Array will return false in the parent's context. Not because there's a bug — because the child frame's Array constructor is a different object than the parent's. They behave identically but they're not the same reference.
This is intentional. The same-origin policy needs this level of isolation to actually hold. If frames shared a JavaScript context, a malicious iframe could read your page's cookies, intercept event handlers, or quietly modify the DOM. Chrome enforces this at the engine level so that even a compromised third-party script stays contained within its own frame.
The cost for us as developers: no shared console. A console.log() inside an iframe writes to that frame's stream, which only shows up in DevTools when you've manually switched to that specific frame context. Miss the timing and the log is just gone.
The DevTools way — and why it falls apart
DevTools does give you an escape hatch: the JavaScript context selector in the Console panel. Click it, pick a frame from the list, and your console commands run inside that frame's environment. It works. Sort of.
The problem is you can only be in one context at a time. If your bug involves two frames talking to each other — a postMessage that fires but never lands properly — you'll never see both sides of the conversation at once. You switch to the child, watch for the outgoing message, then switch back to the parent to check if it arrived. By then you've missed both events.
Intermittent bugs are basically impossible to catch this way. The kind that happen once every few minutes, or only under a specific race condition. You're sitting in the child frame waiting. Nothing happens. You switch back to the parent to check something. The bug fires in the child frame the moment you're not watching. You have to get lucky.
And every frame switch resets your console state. Filters gone. Search gone. Log buffer gone. It's a fresh console every time you switch — as if you'd just opened DevTools for the first time. Not great when you're trying to spot a pattern across dozens of log entries.
The postMessage bridge approach
The better move: instead of chasing logs across frames, make the logs come to you. That's the whole idea behind a console bridge.
You inject a small script into each iframe that patches the built-in console methods. Every time something calls console.log() inside that frame, the patched version runs the original (so normal behavior is completely preserved) and also relays the message up to the top frame via window.top.postMessage. One stream. Every frame. No context switching required.
Receiving logs in the top frame
The parent side is even simpler — just listen for relay messages and route them into your panel. Filter by the sentinel key so you're not catching every random postMessage on the page:
A few security things worth knowing
- Don't use a generic sentinel name like
__bridge__in production — pick something unique to your app so you're not accidentally matching messages from third-party scripts that also use postMessage - The
'*'targetOrigin is fine for local debugging but lock it to a specific origin if you ever leave this running in a non-dev environment - Run your args through
String()before sending — postMessage's structured clone algorithm chokes on DOM nodes and circular references, and it will drop the message silently without throwing
Making it actually reliable
The bridge above works great for straightforward cases. But we hit a few walls building this into onHover's Console Panel, and they're worth knowing about before you run into them yourself.
Error objects don't survive the trip. The structured clone algorithm that postMessage uses can't transfer Error instances, DOM nodes, or anything with circular references. So if your code does console.error(err) where err is a real Error object, the relay will either throw or drop the message silently. The fix is straightforward — check for instanceof Error before relaying and serialize to { message, stack } as plain strings.
Label everything with its frame source. Once you have three or four frames all piping logs into the same listener, the output becomes a wall of noise with no way to tell what came from where. Add window.location.href to every relay payload. Each log entry in your panel gets labeled with its origin frame. This alone is the difference between "technically works" and "actually useful."
Stack traces vanish in transit. The call stack that produced a console.log() exists at the instant that line executes. By the time the message hops through postMessage and lands in the top frame, that context is completely gone. If you need stack traces, capture new Error().stack inside the patched method at intercept time and include it in the payload. It adds overhead but it's the only way to preserve that information.
Keep a reference to the originals. The bridge modifies console methods on the frame's global object. If you need to remove the bridge without reloading the page, you need those original references to restore. Always save them before patching — otherwise the modification is permanent for the lifetime of that frame.
Cross-origin iframes are a different story
Before you spend an hour wondering why the bridge isn't working on a particular frame, it's worth understanding the actual hard line here.
Same-origin frames — iframes you control, served from the same domain — work perfectly with the bridge. You can inject scripts, access contentWindow, patch the console, do whatever you need. Cross-origin frames are completely off-limits. If the iframe is loading from Stripe's domain, or YouTube, or any other external service, the browser will block any attempt to touch its internals. You can't inject, you can't read, you can't intercept. That's the whole point of the same-origin policy.
What you can do with cross-origin frames is listen for the postMessages they intentionally send. Most third-party iframes communicate with the parent page for legitimate reasons — Stripe sends checkout completion events, Typeform sends form submission signals, Intercom sends notification counts. You can observe all of that in the top frame's message listener even without being able to see inside the frame directly.
Quick way to check which situation you're dealing with: open a new tab and paste the iframe's src URL. If it loads from the same domain as your page, the bridge will work. If it's a different domain, you're limited to observing its outbound messages and that's about it.
Where you'll actually run into this
This problem comes up more often than most developers expect. The four places we see it most:
- Storybook — Every component story runs in its own iframe sandbox. When your component throws an error, the console output goes to Storybook's inner frame context. A lot of developers think their component is working fine because the main console shows nothing, when really the errors are firing in a context they're not watching.
- Chat and support widgets — Intercom, Crisp, Zendesk, all of them run in sandboxed iframes. If the widget throws a JS error on a specific user's machine or browser version, you'll never see it in your normal debugging setup because it fires in a context you aren't monitoring.
- Payment flows — Stripe's card input is an iframe. Paddle's checkout overlay is an iframe. If something goes wrong during checkout — a validation error, a network failure, an event not firing correctly — you need cross-frame console visibility to even know something happened. From the outside everything can look fine.
- Analytics and tracking scripts — A lot of tag manager setups load scripts inside sandboxed iframes. Unexpected network calls, console warnings, timing issues — they can all originate in a frame context that's invisible to you by default.
In every one of these cases, the manual DevTools workflow means constantly switching frame contexts, hoping you're in the right one at the right moment, and losing your console state every time you switch. A bridge eliminates all of that.
Pitfalls we hit so you don't have to
The recursion trap. If anything inside your bridge function calls console.log() — even indirectly through a helper or a library — it triggers the patched method, which triggers the bridge, which calls console.log() again. Stack overflow. The rule is simple: save the original methods before patching, and never touch console.* inside the bridge. Use your saved orig references if you genuinely need to log something from within.
Getting there too late. The bridge has to be running before any code in the frame starts logging. If you inject it after the frame has loaded and some initialization has already run, those early logs are already gone — there's no replay. Inject as early as possible. If you're building a browser extension, use document_start as your injection point so the bridge is live before any frame scripts even begin parsing.
High-volume logging will slow you down. Some apps log hundreds of times per second — React re-render debugging, animation loops, WebSocket message handlers. Piping all of that through postMessage across frame boundaries adds real overhead, and you'll feel it during a debugging session. Add level filtering to the bridge early on. Relay only warn and error by default. That cuts the volume by 90% and you still catch everything that actually matters.
Don't try to send too much data. The structured clone algorithm fails on DOM nodes, functions, WeakMaps, and Blob references. When it fails, the message drops silently — no error thrown, no warning, just nothing arriving on the other side. Serialize everything down to plain strings and numbers before sending. The bridge is for visibility, not for reconstructing full data state.
This is exactly what we built into onHover's Console Panel — a Chrome extension feature specifically designed for this problem. When you activate it, the bridge injects automatically into every same-origin frame on the page. You get a single unified log stream with each entry labeled by its source frame — and you never have to touch the frame context selector again. If you're currently chasing iframe logs manually across frame contexts, even a basic version of this bridge will save you more time than you'd expect.
The onHover Chrome extension handles the injection silently, without any setup on your part. No script tags to add to your HTML, no build step, no configuration file. Open the extension, activate the Console Panel, and the bridge is live. It works across your own same-origin iframes immediately — third-party cross-origin iframes require the postMessage relay approach described earlier, since the browser's security model prevents direct injection across origins by design.