Playwright's new_page() Pulled Chrome to the Front. A Background Tab Alone Didn't Fix It.

← hexisteme · notes · 2026-11-04

On a Mac, Playwright's context.new_page() brought a headed browser to the front, so I opened tabs through CDP Target.createTarget with background: true. That call un-hides a hidden browser, so a launch wrapper also re-hides it whenever it isn't the frontmost app, and a one-hour watch of 3 publish runs saw neither browser come to the front.

A blog publishing job ran every 15 minutes on the Mac I work on, and each run opened a browser tab through Playwright. Any of those runs could pull the browser over the app I was using. A stats job at 09:20 could do the same. I have no count of how many runs actually took the screen.

The browsers themselves made this hard to avoid. There were two, both headed and always on, both driven over CDP by scheduled jobs: Chrome for Testing on port 9333 and Google Chrome on port 9334. launchd kept both alive with KeepAlive, so closing one didn't help. It relaunched within 30 seconds and its window came to the front. On October 30, at 18:54 and 21:05, both browsers exited normally (exit 0) and relaunched. My notes record the exit code and not the reason.

That made two separate ways onto my screen: a job opening a tab, and a browser coming back after an exit. The fix has one layer for each, and the second layer is needed because the first one un-hides the browser.

Everything here is Mac-only. open, launchd and NSRunningApplication are macOS pieces, and every result below comes from one Mac. The tab layer is Playwright and CDP, but I only ran it there too.

What I measured

The first row comes from a probe on October 29.

What ranWhat happened
context.new_page() on a headed browserThe browser came to the front and took the focus
CDP Target.createTarget with background: trueFocus stayed where it was, but a hidden browser got un-hidden
open -j, which launches hiddenNo effect, because Chrome shows itself when it opens its first window
open -g on a relaunched Google Chrome with an existing profileThe browser came to the front anyway
bring_to_front() in one other jobCalled on purpose, because that job needs the clipboard

The two open rows ruled out a launch flag as the whole answer. -g is the flag meant to keep a launched app in the background, and it didn't hold for that relaunch. I can't say whether the existing profile is the cause or only the condition I saw it in, because I didn't check why.

The second row shaped the design. background: true solves the focus problem, and a background tab by itself leaves a hidden browser visible again.

The last row was never a bug. That job pulls the browser forward deliberately, and I decided to leave it that way.

Layer one: open tabs behind the app

BackgroundTabs replaces new_page() with the CDP call and hands everything else to the real context.

class BackgroundTabs:
    def __init__(self, context):
        self._context = context
        self._cdp = None

    def new_page(self):
        if self._cdp is None:
            self._cdp = self._context.browser.new_browser_cdp_session()
        with self._context.expect_page() as opened:
            self._cdp.send('Target.createTarget', {'url': 'about:blank', 'background': True})
        return opened.value

    def __getattr__(self, name):
        return getattr(self._context, name)

Reading it from the top:

I wrapped the context instead of editing the browser module because a deployment review pins that module's bytes. Session pages and the re-login tab go through the wrapper too, since any path left on new_page() would still take the focus.

If your browser isn't hidden and you only mind the focus change on new tabs, this layer is all my table calls for. It does nothing for the relaunch path.

Layer two: a launch wrapper that keeps hiding

launchd started each browser through a shell wrapper. This is its launch line:

open -n -g -a "$APP" --args --user-data-dir=... --remote-debugging-port=... --remote-debugging-address=127.0.0.1

-g stays on that line, but the wrapper doesn't depend on it. What keeps the browser out of the way is a hide loop that starts after launch:

The frontmost check is what leaves me a way in. If I switch to the browser with Cmd+Tab or the Dock, it's frontmost, so the loop leaves it visible. By the same rule, a browser un-hidden by a background tab is visible but not frontmost, so the next pass hides it again. That's how the two layers fit together.

The hide is NSRunningApplication.hide, called through the JXA ObjC bridge with osascript -l JavaScript. It's not an Apple Event, so there's no automation permission prompt.

The rest of the wrapper keeps launchd's view of the process correct:

What I checked

Each path to the screen maps to a different check:

Path to the screenLayer that handles itCheck
A job opens a tabBackgroundTabsOne-hour watch
A browser relaunchesLaunch wrapperSimulated Cmd+Q on the real browser
A background tab un-hides a hidden browserHide loopNo direct measurement, only the rule

The temporary-profile check sits outside that table. It's there because a hidden browser is only useful if the jobs can still work in its pages.

What this doesn't show

The evidence is one hour, 3 runs and one Mac. The publishing job was paused at 21:37 that evening by my decision, so this hour is the only watch on record. Some specific gaps follow from that:

The falsifier is specific: either browser comes to the front with no human switching to it, or a hidden browser produces empty screenshots or timeouts. Before calling this settled I'd want a longer watch that includes a 09:20 stats run and a run of the bring_to_front() job, because nothing in that hour's record shows either one.

FAQ

Does Playwright's new_page() take focus on a headed browser?

In a probe on my Mac, context.new_page() brought the headed browser to the front and took the focus. The probe measured the effect, and I didn't check the reason.

How do I open a Playwright tab in the background without taking focus?

I replaced new_page() with a wrapper that sends the CDP call Target.createTarget with background set to true through a browser-level CDP session, inside expect_page() so the caller still gets a Playwright page back. Focus stayed where it was, but this call does un-hide a hidden browser.

Why didn't open -j or open -g keep Chrome out of the way on macOS?

open -j had no effect because Chrome shows itself when it opens its first window. A relaunched Google Chrome with an existing profile came to the front even with open -g. I didn't check why, so the wrapper hides the browser after launch instead of depending on the flag.

How does the wrapper hide the browser and still let a person use it?

For the first 20 seconds after launch it hides unconditionally. After that, every 2 seconds, it hides only when the browser is not the frontmost app, so switching to it with Cmd+Tab or the Dock keeps it visible. The hide is NSRunningApplication.hide through the JXA ObjC bridge, which is not an Apple Event, so there's no automation permission prompt.

What would show this fix is wrong?

Either browser coming to the front with no human switching to it, or a hidden browser producing empty screenshots or timeouts. The evidence so far is one hour, 3 runs and one Mac, and that hour's record shows neither the 09:20 stats job nor the job that calls bring_to_front() on purpose.

Related notes

Fixed your problem? Good — that's the whole point of this page. Every note on this site is free to read. I write one of these up whenever I hit a failure worth recording: agent-fleet operations, harness bugs, and the measurement that proved the fix. The email list for these notes — no issue has gone out yet, so you would be on it before the first one.
← hexisteme · notes · about · editorial policy · privacy · contact · CC-BY 4.0