A fresh browser profile for each test run is the cheapest fix for the flakiest kind of browser test: the one that passes alone and fails after another test has run. The cause is almost always state the previous run left behind, a cookie, a localStorage key, a service worker or a cached redirect. Give each run a profile nobody has used, retire it when the run ends, and that class of failure has nowhere to come from.
Where leftover state hides
Clearing cookies is not a clean start. A browser profile that has visited a site keeps much more than its cookie jar, and each piece can change what the next test sees:
| Left behind | What it does to the next run |
|---|---|
| Cookies | a test meant for a new visitor arrives signed in, or with last run's cart |
localStorage, sessionStorage, IndexedDB | an onboarding flag is already set, so the tour never shows |
| A registered service worker | answers requests from its own cache, so a deploy you are testing is not what loads |
| HTTP cache and redirects | a cached 301 sends the test past a page that has since changed |
| HSTS and permissions | a site remembered as HTTPS-only, a notification prompt already answered |
Each of these makes a test depend on the order it runs in. Clearing them one by one works until someone adds a storage type the cleanup does not know about. A new profile has none of them, by construction.
Everything the window does, from a script on your own computerIn Mosaic: REST API. Everything the window does, from your own scripts. Free holds 3 profiles. Pro is $49 a month for any number.
Mosaic is a desktop app
Email yourself the link and open it on your computer.
Why a fresh profile per test should be cheap
The reason people reuse a profile is that making one is slow or manual. If a fresh one costs a click, or one API call, there is no reason to share.
That is how Mosaic's one-time profile works. In the window, click New profile and choose One-time, which the dialog describes as "Trashed when its browser closes." From code it is one field on the create call. The profile is marked in the list, and the moment its browser stops it moves itself to the trash. Nobody has to remember to delete it, and nobody's cleanup script has to decide which profiles count as test profiles.
That last part is not a small thing. An unscoped empty-trash call in one of our own check scripts once destroyed 586 real profiles, unrecoverably. Since then an empty body on POST /trash/empty is a 400, and every cleanup we write deletes by id. A profile that retires itself is the cleanup that cannot be widened.
One run, from code
The local REST API listens on 127.0.0.1, and a run is three calls with an attach in the middle. Create the profile, with a proxy pasted straight in if the test needs a country:
ID=$(curl -s -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"name":"checkout run","oneTime":true,"proxy":"socks5://user:pass@gw.example.com:1080"}' \
"$BASE/profiles" | jq -r .id)
Start it and take the DevTools endpoint it returns:
WS=$(curl -s -X POST -H "Authorization: Bearer $KEY" "$BASE/profiles/$ID/start" | jq -r .wsEndpoint)
Attach your test runner. Playwright and Puppeteer both connect over CDP:
const browser = await chromium.connectOverCDP(process.env.WS)
const page = browser.contexts()[0].pages()[0] ?? await browser.contexts()[0].newPage()
When the test ends, stop it with POST /profiles/$ID/stop. The stop is a clean shutdown, so cookies and storage are flushed before the browser exits, and the profile then moves to the trash on its own. If the run failed, it is still there for 30 days by default and can be restored to look at what the site set. If you do not want it kept, DELETE /trash/profiles/$ID purges that one profile and nothing else.
Two details save time in a suite. A profile with a proxy does not start until that proxy has answered and tunnelled, so a dead proxy fails the run at start with a reason, instead of producing a test that silently browsed from your own address. And the create call measures the proxy's exit to draw the timezone and language for it; "matchExit": false skips that lookup when where the profile appears to be does not matter to the test.
Contexts, profiles and what each gives you
Playwright's own answer to the same problem is browser.newContext(), and for many suites it is the right one: a new context starts with an empty jar and costs milliseconds. What a context does not change is the browser underneath. Every context in one browser has the same fingerprint, the same User-Agent and the same network path.
That matters when the test is about who the visitor is. Checking a checkout as a first-time customer in Germany, a cookie banner that only shows in some countries, or affiliate tracking that must not see a previous click all need a visitor that differs in more than cookies. A one-time profile has its own empty jar, its own fingerprint drawn from real hardware, and its own proxy exit with a timezone and language to match. The checkout case, step by step, is in testing your own checkout as a customer who has never visited. The affiliate case, where one stale click cookie credits the wrong partner, is in testing affiliate link tracking with a clean jar.
Not every test should be fresh. A suite that tests the signed-in experience needs a user who stays signed in, and making twenty long-lived test accounts easy to tell apart is its own job (keeping twenty QA test accounts straight). The rule we use is plain: if the test is about a new visitor, the profile is new too, and it is gone when the test is.





