I run patchright to transplant cookies for Reddit and Substack reply posting. The host browser holds the real authenticated session. A child context borrows the cookies, opens a tab, hits an in-tab CDP Runtime.evaluate to make the platform's own fetch() call as the real user, and returns the response payload. It works in development because I am sitting there watching the tab. It started silently degrading in production because the host browser runs as a background process and the tab is never on screen.
The failure mode was the worst kind. The CDP call returned. The promise resolved. The payload was an empty string. The platform did not error. The publish worker thought it had a result, parsed it as JSON, got null where the comment ID should have been, and wrote succeeded to the journal. The actual comment did not exist on Reddit.
If you have never touched the Chrome DevTools Protocol, the specific tool here does not matter. The shape of the bug does, and it shows up anywhere you run many browser tabs in one process and expect the ones you cannot see to behave like the one you can. Puppeteer, Playwright, a screenshot service, an end-to-end test suite, a scraper, a social-posting bot: all of them drive Chrome, and Chrome is free to demote a tab it does not consider visible. When it does, your code keeps running and your promises keep resolving, but the results stop arriving intact. This is the story of how I found that out the slow way, and the one line that fixed it.
What I checked first
I checked the network. I assumed the platform was rate-limiting the session or returning a CAPTCHA HTML that was not parsing. I pulled the raw response bytes out of the CDP envelope. They were zero bytes: no CAPTCHA, no error page, an empty payload where a comment should have been.
I checked the cookies. They transplanted correctly. I could Runtime.evaluate document.cookie in the same session and see the auth cookie present.
I checked the auth state by reading window.location.pathname after navigation. It returned the post page, not a redirect to login. The session was good.
I added a console.log inside the fetch body to verify it was running. The log fired. The response object was present. The text body inside the response was empty.
This is where I should have noticed the pattern, because the same thing was happening across two different platforms on the same host. The platform was constant across the failures. The runtime my code was sitting inside was the thing that changed.
The thing that was actually broken
Chrome throttles hidden tabs aggressively. Most of the throttling is documented and predictable. Chrome's own developer-blog write-up on background tabs spells it out: setTimeout clamps (Chrome later tightened the clamp to once per second for chains in background tabs), requestAnimationFrame pauses entirely, and audio context suspends. What is less documented is that some operations completing inside a hidden tab return their results to the calling context in a degraded shape. When you push a fetch() through Runtime.evaluate and the tab does not have focus, the response body's .text() and .json() can resolve to empty without throwing.
I do not know the exact mechanism. I suspect it is a process-isolation thing where the renderer for a hidden tab sits in a different cost bucket than the foreground renderer, and the IPC carrying the response body back to the CDP client gets dropped or truncated. In practice, experience is knowing which abstractions break under load, and this is one I hadn't seen break before. Plenty of documentation talks about timers and animation frames. None of it warned me that in-tab fetch results stop arriving intact.
What I do know is that the fix worked, reliably, every time.
The fix
await client.send('Page.bringToFront');
const { result } = await client.send('Runtime.evaluate', { ... });
One line. Before every Runtime.evaluate call that does meaningful work, push the tab to the front of its host process. The publish call started returning real payloads. The comment IDs came back. The journal stopped writing rows that falsely claimed success. The Page.bringToFront method is documented in the CDP specification, but the effect it has on fetch result delivery is not something you'd find in the docs.
There is a small UX cost. On the host machine the browser window flickers as tabs swap to the front during publish runs. I run the host browser as a background process on a headless server so nobody sees it. If you are running locally you will notice. The cost is fine.
What I would do differently
The deeper bug was not the empty payload. It was that the publish worker trusted the CDP envelope as evidence the platform had accepted the write. The Runtime.evaluate call returning means the JS ran. It does not mean the JS produced a useful result. It does not mean the platform actually persisted anything.
I have since added a persistence check after every publish: re-fetch the resource through the same in-tab CDP path and confirm the comment ID appears in the listing. The persistence check is what catches the rejected_by_platform terminal state on TikTok, and it catches the silently-empty-response failure here. The bringToFront line stops the failure from happening. The persistence check is the seatbelt for the next failure mode I have not learned to one-line away yet.
What else in CDP carries the same shape
I have not done a systematic audit. The ones I am watching now, based on the behavior pattern (call returns but result is degraded against a hidden tab):
Anything that reads computed style or layout. Runtime.evaluate returning getBoundingClientRect() on a backgrounded tab will frequently report zeros for elements that are present in the DOM.
Page.captureScreenshot against a hidden tab returns a frame, but the frame is the last-rendered state, which may be stale by seconds.
Anything timing-sensitive that uses performance.now() will see clamped intervals rather than real wall-clock spacing.
The pattern I am settling on: any CDP operation that requires the renderer to actually do work in the moment of the call should be preceded by Page.bringToFront, even if the docs do not say so. The cost is a tab flicker. The alternative is silent corruption.
If you run a headless Chrome session as a background publish worker, audit what your Runtime.evaluate calls return. The empty-string failure does not throw, and shape-checking tests pass. You see it only when the platform artifact you expected never appears.
The wider lesson
I reached this through the Chrome DevTools Protocol, but the failure is not a CDP failure. It is a Chrome failure, and it surfaces through whatever you happen to be driving Chrome with. The trigger is the same in every case: a real browser tab that does meaningful work while it is not the foreground tab. The protocol is incidental. The visibility assumption is the bug.
I had been treating "the call returned without throwing" as proof the work happened. In a deterministic single-process program that is usually safe. The moment your code runs inside a browser tab whose execution priority the browser is free to downgrade, the return of a call stops being proof of anything except that control came back. The same shape shows up in mobile apps that throttle background work, in serverless functions frozen mid-request, in any runtime that can quietly demote your code while still letting your promises resolve. The defense is the same everywhere: do not trust the dispatch, confirm the effect.