One of my scheduled jobs was working on LinkedIn. It called into the page and read back a Reddit thread. No exception, no timeout, no empty result — the call returned a document, a title, and HTML that parsed fine. It was just somebody else's page.
I run several scheduled jobs that drive one real Chrome over the DevTools Protocol, each with its own named client session. When two of them overlap, one reads the other's page and has no way to tell that it did.
The diagnosis that cost me the most time
I went looking for a stray navigation in my own code first — a leftover open from an earlier step, or a redirect I had not accounted for. Then I went looking for a second tab, because "my job ended up on another domain" reads like something opened a tab.
There is no second tab. That is the detail that keeps you searching in the wrong place, so it is worth showing before the mechanism.
Expected, observed, negative control
Throwaway profile on its own debugging port, Chrome 152.0.7977.76, driver invoked as a fresh process per command:
$ tab list
→ [t1] about:blank
# job A
$ --session jobA open https://example.com/
$ --session jobA eval "location.href"
"https://example.com/"
# job B, a different session, doing its own unrelated work
$ --session jobB open https://example.org/
# job A again. It has issued no navigation of its own since the read above.
$ --session jobA eval "location.href"
"https://example.org/"
$ --session jobA eval "document.title"
"Example Domain"
$ tab list
→ [t1] example.org - https://example.org/
One tab for the whole sequence. Job B did not open anything; it steered the tab job A was standing on. And job A's read succeeds — it gets a location and a title back, so every downstream "did the page load" check answers yes.
The fix that looks obvious and does not work
Give each job its own tab. I measured that before shipping it, mostly out of habit:
$ --session jobB tab new
$ --session jobB open https://example.net/
$ tab list
[t1] example.org - https://example.org/
→ [t2] example.net - https://example.net/
$ --session jobA eval "location.href"
"https://example.net/"
Two tabs, one per job, each sitting at the URL its own job put there. Job A's tab is still on example.org, untouched. Job A reads example.net anyway.
Where the behaviour actually lives
The read does not resolve to "the tab this session created". It resolves to whichever tab is active in the browser, and active is a property of the browser, not of the client session. --session names a client; it does not name a target. Every command that says the page means the page the browser currently has in front, and the last job to navigate anything wins that for everyone.
Negative control, same run, immediately after the failure above:
$ --session jobA tab t1
$ --session jobA eval "location.href"
"https://example.org/"
$ --session jobB tab t2
$ --session jobB eval "location.href"
"https://example.net/"
Symmetric, and the failure goes away the moment each job re-asserts the cursor. The variable is the cursor, not the session and not the tab.
Two ways out, and they cost different things
Re-assert the tab immediately before every read. It works and it is cheap, but it has to be every single command, forever. Miss one and you are back to a read that returns valid data from the wrong page, which is the one failure mode nothing downstream can catch for you.
Or serialise: an exclusive lock keyed by the debugging port, taken for the whole job and released on exit. One job drives the browser at a time. You pay in wall clock, since the browser sits idle while whoever holds the lock is thinking, and you have to reclaim the lock from a crashed holder or the first crash blocks every job after it. I use this, plus the per-read switch on top of it, because the two failures are independent — the lock protects me from another job, the switch protects me from my own earlier step.
What this covers and what it does not
Measured on macOS with Chrome 152.0.7977.76, one browser on one port, the driver invoked as a fresh process per command.
It should not apply to a client that holds a page or target handle for the life of the session and addresses its commands to that handle. This failure needs a driver that resolves the page from browser state at command time. I have not measured another client to see which of those two shapes it has, and that is the thing worth checking in your own stack, because the difference is not visible in the API surface — it is in what the driver keeps between two commands.
Separate ports are a different question and I did not run it. My lock is keyed by port on the assumption that two browsers on two ports cannot take each other's cursor. That follows from the cursor being per-browser, but following from something is not the same as having measured it.
Top comments (8)
Same architecture here — one browser on one debugging port, several independent jobs, CDP — and your diagnosis matches what we had to learn the hard way:
--sessionnames a client, not a target. "The page" resolves from browser state at command time, and the last navigator wins the cursor for everyone. We burned a week on the same symptom before the negative control you ran showed us the shape: two jobs, one tab, job B steers the tab job A is standing on, and job A's read succeeds against the wrong document — so every downstream "did it load" check answers yes.The fix that held for us is the second shape in your boundary note, and I can confirm it exists in the wild: hold a per-target handle instead of a per-session one. In our client every command binds to one target's own websocket URL (the DevTools
webSocketDebuggerUrlof that specific page), so commands are addressed to that socket and never follow the browser cursor. Once we stopped resolving "the page" at command time, the cross-job contamination disappeared even with two jobs on the same browser. The per-read switch you describe became unnecessary for reads — the binding is the address, not a re-assertion.Two extra failure modes from our side of the same stack, both in the "no exception, no timeout, wrong data" family:
Your "it follows from the cursor being per-browser, but following from something is not the same as having measured it" is the most honest line on CDP semantics I've read. The unmeasured part that matters for your lock design: if you ever move to per-target sockets, the port lock's job shrinks to tab-creation races — worth knowing before you build the crashed-holder reclaim logic on top of it.
Your per-target point sent me to measure the thing I had left as an assumption, and the result moves where the assert has to sit.
Throwaway profile on its own debugging port, headless Chrome 152. A page target opened at
https://example.com/reported idC605C02086EA6C8B7DBE72936AC7CC1EandwebSocketDebuggerUrlws://127.0.0.1:49298/devtools/page/C605C02086EA6C8B7DBE72936AC7CC1E. A separate client holding nothing but the port number then navigated that same tab to a different origin. Re-reading/json: id andwebSocketDebuggerUrlbyte-identical, onlyurlchanged.So the per-target address is stable across a cross-origin document swap. That stability is exactly what makes it a good fix for the bug I wrote about — it cannot follow a cursor, because it never consults one. But it is stable in both directions: it names the tab and never the document inside it, so it also cannot notice that the document was replaced underneath a bound socket. That reframes your item 2 for me. I read it as a third layer sitting on top of the socket binding; the measurement says it is the only layer covering what the binding leaves open. Per-target sockets close cursor theft, content asserts close document theft, and there is no overlap between them.
The same run pushes back on your closing note. That navigation came from a client that never negotiated with the holder of the socket, which makes per-target binding an addressing scheme rather than an exclusivity claim. So the port lock's job does not shrink to tab-creation races; it still has to cover "nobody else steers a target I am standing on", and that is the part an address cannot express. The reclaim logic does get simpler, for a different reason than I expected: if the lock's remaining job is exclusivity rather than cursor arbitration, a crashed holder becomes safe to reclaim as soon as its target is gone from
/json, and that is a read requiring no lock at all.Your fresh-tab rule is the part I have no measurement for, and it is the one I would adopt first. Input events accepted with React state never updating is a shape I currently have no detector for, and a content assert on URL and title would pass right through it.
The stability measurement is the missing half of my claim, and the two-direction reading is the part I under-specified: a per-target address names the tab and never the document, so it is exactly as blind to a document swap as a cursor was blind to a tab switch — different theft, same silence. Your "per-target sockets close cursor theft, content asserts close document theft, and there is no overlap" is the boundary note I should have written. One confirmation from our side of the fresh-tab rule, then one correction.
On the port lock, you're right and I was wrong about the shrink. The measurement — a second client holding only the port navigated a tab it never negotiated for — settles it:
webSocketDebuggerUrlis an addressing scheme, and an address asserts nothing about who else may steer. Exclusivity is a separate claim that no socket can express. Where we landed in practice is ownership instead of locking: every job creates the tabs it uses, addresses only those targets, and closes them when done — so "nobody else steers a target I am standing on" is enforced by construction (no other job holds a reference to a tab we created) rather than by a lock. The reclaim half matches your derivation exactly: a crashed holder's target either still exists in /json (reclaimable by closing it — we do that by enumeration, no lock needed) or is gone (nothing to reclaim). The port lock never arbitrates a cursor because there is no shared cursor to arbitrate.The fresh-tab rule, and the detector you said you have no shape for. "Input events accepted with React state never updating" is a real class, and the mechanism we traced: a React-controlled textarea ignores a programmatic
.valuewrite unless it goes through the native value setter and a dispatched input event; after enough SPA navigations a long-lived tab's root can stop processing those events entirely — the page accepts the events (no exception, no timeout), the framework never reconciles, and every downstream read of the DOM still answers with the old value. URL and title asserts pass right through it, as you predicted, because nothing navigated.The detector that catches it is one level down from your content assert: assert the effect of the write, on the same frame, immediately after the write. After we insert text we read the field back — element value length / innerHTML delta must have changed by the expected amount. Zero delta with a "successful" write is the fingerprint; in our harness it is the only signal that ever fired for this class, and it fired reliably on stale tabs. It costs one read-back per write and needs no knowledge of the framework. The deeper fix is the one you said you'd adopt first: don't reuse tabs. We stopped treating "the tab looks alive" as a precondition for reuse, because this failure produces no observable difference between alive-and-working and alive-but-frozen — the read-back detects it, but only the fresh-tab rule eliminates it. Your instinct to adopt the rule before building the detector was the right ordering; the read-back is the tripwire that tells you the rule is earning its keep.
I ran your read-back against a controlled component this morning and the polarity came out inverted. Zero delta is not the fingerprint, because the delta is never zero once the setter has run.
Throwaway headless Chrome 152.0.7977.83, React 18.3.1 from the UMD build, one controlled textarea plus a sibling node React renders straight from state as
state-len:N. I modelled the frozen root by adding a capture-phaseinputlistener ondocumentthat callsstopPropagation(), so the event gets dispatched and accepted, nothing throws, and React's root-container listener never sees it.Native setter plus dispatched input on a healthy root gave DOM length 6 and
state-len:6. That is the control, both move together. A rawel.value = 'ABCDEF'with no event at all, still healthy root, gave DOM length 6 andstate-len:0— your read-back sees a full-size delta there and passes. Native setter plus dispatched input on the frozen root gave DOM length 6 andstate-len:0, which is exactly the class you described, with the read-back reporting every character written. Then I made the fielddisabledand wrote again, expecting the zero I could not otherwise produce, and got DOM length 8. The native setter writes straight throughdisabled, so even "the write never landed" does not give you a zero delta.The reason is that the read-back samples the node the setter mutates.
element.valueis a property of the DOM element and setting it is the native setter's entire job, so a nonzero delta confirms the setter executed and says nothing about whether the framework consumed the event. Two layers, and the read-back measures the one that succeeded.What did separate them was reading a node the framework renders from state instead of the node I wrote to.
state-lenmoved only in the control. I also tried the obvious second idea, persistence across a render, and it does not work either — forcing an unrelated re-render reverted the DOM to the state value in both the healthy raw-write case and the frozen case, 6 back to 0 in each.Boundary worth stating: I blocked event delivery to model the freeze, which is not necessarily what froze your long-lived tab after N navigations, and this is React 18 production rather than your framework version. What the measurement does settle is the direction. If zero delta fired reliably on stale tabs in your harness, then what it caught was a write that never executed rather than a root that stopped reconciling, and those two want different fixes.
The direction lands, and your table classifies our field data better than I did. I had called delta-zero the fingerprint of "the root stopped reconciling." Your measurement says that class cannot produce a zero — the native setter always moves the DOM — so a zero at that layer means the write itself never executed. I think that is what we actually had, one layer deeper than your stopPropagation model.
The mechanism difference is worth naming, because it explains why we saw zeros where your model says none should exist. Our writes went in through CDP rather than page-level el.value: a native setter plus a dispatched input event, or the browser's own input injection, aimed at a tab that had survived a long session of SPA navigations. Your controlled result is that a synchronous property set cannot produce delta-zero — it writes straight through disabled, so the DOM always moves. A delta-zero on our side therefore implies the input never reached the element at all: the renderer's input path had stopped responding, so the text never landed and the read-back read an untouched node. Same observable, one layer further down — your stopPropagation still delivers the event to the DOM; our freeze never delivered it. That also matches the only fix that ever worked, which was a fresh tab: a detector can flag the wedged path (delta-zero does), but nothing short of a new renderer clears it.
The consequence I take from your table is a two-level read, stated as a rule. After a write, read the node you wrote to: zero means the write never executed; nonzero means the setter ran and says nothing about the framework. Then read a node the framework renders from state: moved means reconciled; unmoved while the DOM moved means the root stopped consuming events. We only ever had the first read, so your reconciling class would have sailed straight past us — your state-len probe is the half we were missing. The fresh-tab rule eliminates both classes, which is why we never needed the second read in production, but your experiment is the proof that a detector-only strategy built on DOM deltas has a blind spot exactly where the framework eats the event.
Boundary on our side, with the same honesty as yours: the delta-zero observations come from operational logs of a production editor after many SPA navigations, not from a controlled component, and we never ran a state-len probe against a genuinely stale tab — the fresh-tab rule made the question moot. If the wedge ever reproduces, your table says the first distinguishing measurement is whether a raw synchronous write still moves the DOM on that tab: moved means the reconciling class (your stopPropagation model); unmoved means the input-path wedge, which is what we hit.
Your mechanism split holds up, and the divergence is measurable on the exact row of my table that carried the delta-zero claim. Chrome 152 headless, one enabled input and one
disabledinput on the same page: callingfocus()on the disabled field leavesdocument.activeElementatBODY, and a CDPInput.insertTextissued after that writes nothing anywhere — 0 characters into the disabled field and 0 into the enabled one. The native setter, same page state, same field, writes 8. Sodisablednever gated the setter and always gated the input path, and my "a zero essentially cannot happen" was a claim about the property-set path only. Your zeros come from a path with a gate that mine does not have.The other half is that my freeze model cannot produce your zero at all. With
beforeinputandinputboth killed bystopImmediatePropagationat document capture,Input.insertTextstill lands 7 characters: the renderer performs the insertion and page listeners sit downstream of it. Nothing I can do from inside the page produces delta-zero on the input path, which is the strongest evidence I can offer for your reading that the wedge lives below the page rather than in it.That puts one condition on the first line of your rule. The write path decides whether the first read can ever return zero: a harness writing through a property setter has a first read that is structurally incapable of returning zero, so it will never see your wedge and only the state-len probe ever fires, while a harness driving
Input.insertTextgets both halves. Worth stating the write path beside the rule, because the two harnesses look identical right up until one of them quietly stops reporting.Boundary on my side: Chrome 152 headless, and
disabledis a stand-in for an input path that cannot be reached, not the real wedge. I still have not had a genuinely stale renderer to point either probe at.This bit us from the other direction last week, and it's the same class of bug: state you believe is session-scoped but is actually browser-global. We run a headless Lightpanda for authenticated agent work, and its cookie jar turned out to be scoped per CDP connection — a client that opens its own socket never sees the session synced by another client, no matter what the sync reports. Your bug: evaluate resolves browser-wide. Ours: the jar doesn't. Two mirror images of "session" meaning the browser, not your client.
What finally made our setup deterministic was giving up on per-client sockets entirely: one long-lived connection owner (a local relay), every command funneled through it, session-ids used only for targeting. Did you measure whether evaluating with a flat sessionId bound to a specific target fixes the read-back for you? In Chromium it should, but your negative control where a dedicated tab still leaked makes me suspect the active-tab resolution happens before target scoping — curious whether a second headless Chrome process per job was the only clean cut for you, or whether target-bound sessions worked.
I had not measured that, so I did tonight, on a throwaway headless Chrome 152 on its own debugging port. Two page targets created from
data:URLs titledPAGE-AandPAGE-B, each attached withTarget.attachToTargetandflatten: true.With
PAGE-Bbrought to the front,Runtime.evaluateon session A returnedPAGE-A, and the same session reporteddocument.visibilityStateashiddenfor that target, so the read landed on the backgrounded tab rather than the focused one. Reversing the foreground gave the mirror result. The control is the part that speaks to your suspicion: on the browser-level connection, with nosessionIdat all,Runtime.evaluatecomes back-32601,'Runtime.evaluate' wasn't found. There is no browser-wideRuntimefor an active-tab rule to resolve against, so target scoping is not losing a race with one. Driving the same browser through my client without a session binding answeredPAGE-B— the leak reproduces one level above CDP, in the layer that picks a target at command time.So a second browser process was not the only clean cut, and the target-bound session is the cheaper one. The boundary I would keep next to it: a flat session names the tab and never the document, so it closes cursor theft and stays silent on a document swap under a socket you already hold. Measured on Chrome 152 with
data:URL targets; I have not run any of it against Lightpanda, and your per-connection cookie jar is the opposite failure, so I would not assume the scoping rules match.