Skip to main content

Session profiles

Every session starts in a fresh browser context. That is usually what you want — right up until reaching the interesting page means logging in or clearing a captcha by hand, and you have to redo it for every session.

A profile saves that work.

npx hrserve ./public --url https://app.example.com/ --save-profile login
# ...log in in the browser window, then press Ctrl-C to capture...

npx hrserve ./public --url https://app.example.com/ --profile login # already signed in
npx hrserve profiles # list what's saved

Programmatically:

const server = createServer(browser);
await server.serve({ url: "https://app.example.com/", dir: "./dist", profile: "login" });
// ...do more manual steps in the page...
await server.saveProfile("login-with-2fa");

Profiles are immutable snapshots

A profile is one JSON file holding Playwright's storageState: cookies, per-origin localStorage and IndexedDB. Sessions read profiles and never write back — saveProfile() is the only way state is persisted, and it always writes a new name.

That single rule is what makes branching trivial:

--save-profile A # fresh; sign in by hand → A
--profile A # B starts from A
--profile A → save as C # C starts from A too, concurrently, and adds more
--profile C # D starts from C

Two sessions can run from the same profile at the same time without interfering, and starting from A gives the same result no matter what C did afterwards.

There is no fork command, because forking is "start from X, save as Y" — a name for a pair of existing operations, not a new capability. Each profile records the parent it branched from, shown by hrserve profiles; that lineage is descriptive only, since every snapshot is complete on its own.

Why not a browser profile directory?

Playwright offers two ways to carry browser state, and only one fits hrserve:

storageState (used here)launchPersistentContext(userDataDir)
What it isa JSON valuea directory on disk
Carriescookies, localStorage, IndexedDBeverything, incl. service worker caches, extensions, WebAuthn
Concurrencyany number of sessions from one profileChromium locks it — one browser process per profile
Branchingcopy a small filecopy tens of MB, and only while unused

hrserve's whole point is running many sessions at once. A user-data directory cannot be shared between two of them, which would kill the branching above — so profiles are snapshots, and the fidelity limit is stated rather than hidden.

A profile does not capture sessionStorage, service worker caches, HTTP auth, WebAuthn credentials or browser extensions.

Two things to know

A profile is a credential file

It contains live session cookies. Profiles are stored per-user outside your project$XDG_DATA_HOME/hrserve/profiles/ (default ~/.local/share/hrserve/profiles/), mode 0600. Never commit one.

Profiles are origin-scoped

storageState belongs to the origins it was captured on, and hrserve deliberately serves at arbitrary origins. A profile captured on https://app.example.com does nothing for a session served at http://app.hrserve.test/.

hrserve warns when the profile doesn't cover the URL you're serving, naming both — because the alternative is a silently logged-out page that looks like a bug in the site.

Session cookies also expire; hrserve profiles shows capturedAt so you can tell a stale profile from a broken one.

With agent sessions

Sessions started through the SessionManager or MCP take a profile too, so an agent can work against an authenticated app without ever handling credentials:

await manager.start({ name: "feature-a", dir: "~/wt/a", profile: "login" });
await manager.get("feature-a").saveProfile("login-plus-cart");