Solari cloud browser sessions end about ten minutes after they start. outlive checkpoints the session, notices the death, launches a new browser, and calls your task again where it left off.
Read this first, because it is the whole shape of the thing. A checkpoint is
cookies, localStorage and the current URL. That is all that survives. The DOM
is gone, anything the page held in JavaScript is gone, a half-typed form is
gone, and your task function starts again from the top. A task that is not
safe to run twice has to be written so that it is — checkpoint straight after
the step that must not repeat, and check for that step's effect on re-entry.
outlive puts this in the API rather than hiding it: your task is called again,
with ctx.attempt and ctx.resumedFrom.
npm install outliveimport { Solari } from "@solarisdk/browser"
import { outlive } from "outlive"
const solari = new Solari({ apiKey: process.env.SOLARI_API_KEY })
const rows = await outlive(solari, async (page, ctx) => {
if (!ctx.resumedFrom) {
await page.goto("https://bank.example/login")
await signIn(page) // password + 2FA, the expensive part
await ctx.checkpoint() // never do that twice
}
return await scrapeForTwentyMinutes(page) // outlives the session
}, { checkpointEveryMs: 30_000, maxRelaunches: 5 })- The session dying is not an error. It is expected, it is handled, and it costs about two seconds.
- A step can replace the browser itself.
ctx.relaunch()hands your task a new browser, restored from the last checkpoint, without calling it again — for the code that is holding aPagein the middle of something and cannot start over. - Liveness comes from the connection, never from the API.
GET /sessions/:idreportedactivefor a session that had been dead for five minutes. outlive uses thedisconnectedevent,isConnected(), and the"Browser closed"message substring as a last resort. - One wide event per run, quiet by default: outcome, relaunches, checkpoints, lost work, total time.
- One runtime dependency,
@solarisdk/browser.playwright-coreis a peer dependency and is used for types only — outlive imports no value from it. Note that the SDK actually returnspatchright-core'sPage: patchright is a Playwright fork with the same runtime surface, and the two declarations differ only in optional-property variance, so the object you get behaves exactly like thePagethe types describe. handraise makes the same choice, so the series is consistent; the cost is that a consumer who has no Playwright installed getsplaywright-corepulled in for its.d.tsfiles.
Part 2 of a series with handraise, which handles the other way a Solari session stops being useful: it needs a human.
Against the live API. Method and raw data in
benchmarks/ and
docs/measurements/.
- 5/5 tasks of 12 minutes completed. Baseline 0/5. Ten task runs, fifteen
browser sessions, arms concurrent and alternating, against one live Aurora
Bank instance: sign in with a real TOTP, then poll the account page every 20 s
until 36 polls have succeeded — 720 s of sleeps against a session that lives
~600 s. Every baseline run reached poll 29 or 30 and threw
goto: Browser closed. - 5 relaunches, no failures. Median lost work 4.9 s per run, worst single death 15.0 s, at a 30 s checkpoint interval. No session was left unreleased, no checkpoint capture failed.
- The overhead does not show up in the wall clock. outlive's median run was 756 s. The baseline's own pace — 29 polls in 624 s, 21.5 s each — extrapolates to ~774 s for 36, so surviving a session death cost less than the run-to-run variation. The cost that is measurable is the lost work above.
- 2121 ms to replace a browser: launch + context from
storageState+goto. The checkpoint itself is 200 ms and 1640 bytes.
An earlier run of the same bench drew a browser that was dead 1.5 s after
launch() returned. The baseline lost a whole run to it; outlive relaunched and
finished. Sessions do not only die of old age.
The live end-to-end test (bun run test:e2e) is the same claim without a
control: sign in once, then poll for fourteen minutes. It now takes both kinds
of replacement in one run. Latest run — 40/40 polls, two relaunches, one TOTP
typed, 824 s total, 28 checkpoints:
- after poll 2, at 33 s, the task called
ctx.relaunch()on a browser that was still healthy. The replacement came back on the same account URL, already signed in — no second TOTP — and the task carried straight on in the same entry:attemptsstayed at 1 through the next twenty-nine polls. The page it had been called with refused, as it should:title: Target page, context or browser has been closed. - at 640 s the platform ended that second session by itself, and the ordinary path took over: one death, 2.9 s of repeated work, the task re-entered and polling continued to 40/40.
An earlier run of the same test took two deaths, one of them at 193 s, and finished the same way.
Solari browser sessions have no timeoutMs, no keep-alive call, no resume()
and no option that asks for a longer life. Six measured sessions died at
604–617 s from creation — one at 319 s — and it made no difference whether they
were idle, pinged every 25 s, or streaming a CDP screencast at 14 fps. The idle
one lived longest. expiresAt sits at creation + 5 h and is never reached. The
full measurement is in
handraise's measurement 04.
So any agent task longer than about ten minutes dies part-way through, and
because the control plane keeps calling the corpse active, it dies quietly.
| Thing | Survives a relaunch |
|---|---|
Cookies, including HttpOnly ones |
✅ |
localStorage |
✅ |
| The page's URL | ✅ |
| A logged-in application session | ✅ (measured 3/3) |
| The DOM, in-page JavaScript state, scroll position | ❌ |
| A half-filled form | ❌ |
IndexedDB, service workers, sessionStorage |
❌ |
| Local variables inside your task function | ❌ — it starts again |
| Local variables in the scope around your task | ✅ — they are yours |
Two things about the re-entry itself, because neither is obvious:
Your task is not cancelled when its browser dies — it is abandoned. A
promise cannot be interrupted, so the old entry keeps running until its next
page call throws. For that window, two entries of your task are running at
the same time. Page calls from the abandoned one fail harmlessly and its
ctx.checkpoint() is retired, but anything it does outside the browser — a
database write, an HTTP POST, a counter — still happens. This is the same
constraint as "safe to run again", one layer down.
A death waits deathGraceMs (250 by default) before the replacement is
launched. The disconnected event arrives about 1.5 s before a page call
would notice, so a task whose last call already succeeded is usually still
resolving. Waiting lets it finish instead of throwing the work away. It is a
threshold, not a guarantee: a task that spends longer than that parsing or
writing after its final page call is re-entered anyway. Correct, because tasks
must be re-runnable — but it is the expensive outcome the grace exists to avoid,
so checkpoint before a long non-browser tail. It is not the window for
ctx.relaunch(), which is longer; the next section says exactly how long.
That last row is how a task keeps a running total across relaunches: keep it in your own closure, not inside the task.
const rows: Row[] = [] // survives: it is yours
await outlive(solari, async (page, ctx) => {
for (const url of remaining(rows)) {
await page.goto(url)
rows.push(await scrape(page))
await ctx.checkpoint()
}
return rows
})Runs task and resolves with whatever it returns. outlive owns the browsers: it
launches them, replaces them, and closes every one of them before returning. The
Solari client stays yours and is never closed.
It throws three ways:
- your value, unchanged and by identity, if your task rejected with
something that was not a session death — outlive does not retry your bugs,
and it does not coerce your rejection into an
Erroreither. Reject with an object and you get that object back; OutliveErrorwithcode: "gave_up", if the browser died more thanmaxRelaunchestimes;OutliveErrorwithcode: "invalid_option", before anything is launched, if an option is not a number outlive can use.
ctx.relaunch() can also throw OutliveError with code: "checkpoint_failed"
or code: "abandoned", and those reach you only if your task lets them: they
are thrown into your code, not out of outlive(). A run that ends because one
of them escaped reports outcome: "failed" with that error — outlive hands a
task's error back by identity rather than reinterpreting it.
Do not close the browser yourself. outlive owns it, and page.context() .browser().close() is indistinguishable from the platform ending the session:
outlive will treat it as a death and relaunch.
ctx |
|
|---|---|
attempt |
how many times your task has been entered: 1, then 2 after it was re-entered on a fresh browser, … This is the re-entry signal. It counts entries, not browsers: a ctx.relaunch() replaces the browser under a running entry and leaves attempt alone |
resumedFrom |
{ url, checkpointAt } when a checkpoint was restored into this browser before the entry started. Absent on the first entry, after a death that happened before the first checkpoint existed, and for every browser ctx.relaunch() brought in — which is why attempt and not this is the signal |
checkpoint() |
capture cookies + localStorage + URL now. Never throws; resolves true if a checkpoint was written, false if it was not |
relaunch() |
replace this browser now and carry on in the same entry; resolves with the new Page. See below |
| — | page is a playwright-core Page, already navigated to resumedFrom.url when there is one |
page is a playwright-core Page, already navigated to
resumedFrom.url when there is one.
| Option | Default | |
|---|---|---|
checkpointEveryMs |
30_000 |
automatic capture interval |
maxRelaunches |
5 |
~5 sessions ≈ one hour of task |
relaunchBackoffMs |
2_000 |
wait after a launch that failed; a death relaunches at once |
launch |
{} |
passed to solari.launch() for every browser. { retries: 2, probe: true } is worth considering: one browser in ten came back already dead |
viewport |
1280×800 |
|
startUrl |
– | navigated to before the first entry only |
navigationTimeoutMs |
45_000 |
cap on the resume goto |
deathGraceMs |
250 |
how long a dead browser's task gets to finish before the replacement is launched. Not the ctx.relaunch() window — see below |
diagnostics |
"safe" |
"full" puts raw URLs and raw error text in the logs and the event. See Security |
onEvent |
– | called once per run with the wide event |
logger |
quietLogger |
consoleLogger, noopLogger, or your own sink |
One per run, on every path including the ones that throw:
{
"runId": "3f2a91c7-6b40-4a1e-9d2c-8f5e0b71c4aa",
"outcome": "completed",
"relaunches": 1,
"manualRelaunches": 0,
"checkpointCount": 24,
"lostWorkMs": 2315,
"maxLostWorkMs": 2315,
"totalMs": 751943,
"attempts": 2,
"releaseFailed": 0
}Those counters are run 3 of the outlive arm in
benchmarks/survival.json, copied from the file by
scripts/sync-readme-event.ts. Only runId is invented, because the bench does
not log it.
outcome is completed, failed (your task threw, and you get that error
unchanged) or gave_up (out of relaunches — you get an OutliveError with
code: "gave_up").
lostWorkMs is defined exactly: at each death, the milliseconds between the
last completed checkpoint and the moment the death was detected, summed
over the run. Not the moment the browser finished closing, and not including the
250 ms grace. If the newest checkpoint is older than the entry that died, the
measurement starts at the entry instead, so no second of wall clock is counted
twice. maxLostWorkMs is the worst single death by the same rule. Lower
checkpointEveryMs to buy them down — a capture costs ~200 ms.
manualRelaunches counts the browsers ctx.relaunch() actually handed to your
task; the rest of relaunches were deaths outlive answered itself and launches
that failed. One budget, two stories: a run with relaunches: 2, manualRelaunches: 1 burned two sessions, one of them because the task asked.
relaunches is one unit per launch attempt, not per browser. A death whose
first replacement fails to launch spends two: the failed attempt and the one
that worked.
releaseFailed counts sessions outlive could not confirm were released
after a close() failed. It should always be 0; anything else means a browser
is still holding a slot.
It carries no secret outlive itself holds: no API key, no cookie values, no
checkpoint, no URL. errorName and errorCode are the classifications you
would group by, and error is the message after every URL has lost its path
and query, credential-shaped name=value pairs have been blanked, and the
whole thing has been clipped to 300 characters.
That last part is a net, not a guarantee — error is text your task produced,
and outlive cannot know every shape a secret takes. diagnostics: "full" skips
the scan entirely; use it where you trust the sink.
Sometimes the task cannot start again from the top, and re-entry is the wrong
answer. ctx.relaunch() is the other one: it replaces the browser without
calling your task again.
await outlive(solari, async (page, ctx) => {
let active = page
await signIn(active)
await ctx.checkpoint()
// The one event that fires when the platform ends the session — about 1.5 s
// before any page call would notice. This is what a library holding your page
// listens to, and it is the right place to ask for a replacement.
let rescue: Promise<Page> | null = null
active.context().browser()?.once("disconnected", () => {
rescue = ctx.relaunch()
// Awaited below, so keep a rejection from becoming an unhandled one while
// the task is still somewhere else.
rescue.catch(() => undefined)
})
for (const row of rows) {
try {
await process(active, row)
} catch (error) {
if (rescue === null) throw error // not a death: your bug, your error
active = await rescue // same entry, new browser
rescue = null
}
}
})It resolves with a Page from a fresh browser holding the last checkpoint's
cookies and localStorage, already at its URL — the same restore a death gets,
without leaving the function. It works whether the current browser is already
dead or still alive.
How long you have. When the browser dies, outlive waits deathGraceMs
(250 ms by default) for your task to finish on its own, and then launches the
replacement itself. A relaunch() that arrives at any point before that browser
is handed to a new entry joins the launch already in flight: the re-entry is
cancelled, and your call resolves with that page. So the window is the grace
plus the launch — about 2.4 s measured, not 250 ms — and it is a window you
can widen with deathGraceMs. Past it, relaunch() throws abandoned, because
by then your task is already running somewhere else.
- The
pageyou were called with is dead afterwards. outlive closes the browser it belonged to, so every call on the old page rejects with"Browser closed"— and because the current browser is alive, that rejection fails your run instead of being read as a session death. Use the pagerelaunch()resolves with, and make anyLocatororCDPSessionagain. - It spends one of
maxRelaunches, exactly as a death does. The budget is one unit per launch attempt, wherever the attempt came from. - A live browser is checkpointed first, and if that capture fails nothing is
replaced: you get
OutliveErrorwithcode: "checkpoint_failed"and the browser you had. Resuming from a stale checkpoint would move your task backwards — or toabout:blank— without telling you. - A browser that died before your first checkpoint has nothing to restore,
and then the page you get back is blank and signed out — the log line says
restored: false. The platform can end a session 1.5 s afterlaunch()returns, so checkpoint early. - Two calls while one is in flight are one relaunch — the second gets the same promise — and a relaunch racing outlive's own death handling produces exactly one new browser, never two.
ctx.checkpoint()and the automatic captures follow the new browser, andctx.attemptdoes not change: this is a continuation, not a re-entry.- It throws
OutliveErrorwithcode: "gave_up"when the relaunch budget is spent, andcode: "abandoned"when the entry is over. Neither closes anything: in both cases the browser you have is still yours.
Why it exists. handraise is handed a
Page — it streams the browser to a human's phone and waits for a decision —
so when the session ends mid-handoff it cannot replace a session it does not
own, and re-entry would cost the human their link and their QR scan;
ctx.relaunch is the smallest surface that lets that step keep running on a
replacement browser, and the next handraise release passes it straight through
as reattach. The reasoning, the contract and the alternatives that were
rejected are in ADR 0002.
Three signals, and the control plane is not one of them:
browser.raw.once("disconnected")— fires first, about 1.5 s before anything else notices.browser.isConnected()— local socket state, free, truthful.- the message substring
"Browser closed"— the last resort, for a call that threw before either of the above was checked.
patchright's TargetClosedError has no code and no status; its
constructor.name is minified and its name is plain "Error". The substring
is the only stable marker, which is why it is third and not first.
Neither signal is reliably first, so both directions get deathGraceMs. A
task whose last call succeeded is usually still resolving when the death
arrives, so a death waits for it. And a page call with a short timeout of its
own can fail before the socket notices — measured at 394 ms in a live run —
so a task error waits the same window for a death before it is believed. A
genuine failure therefore takes one grace window on its way out, and a genuine
death is never reported as your bug.
A checkpoint costs ~200 ms on the same connection your task is using, so the
default of 30 s spends about 0.7 % of a busy task's time. It is a target
interval, not a cap: a capture can fail — the browser is already dying, the
round trip times out — and then the next death costs more than one interval.
await ctx.checkpoint() returns false when that happens, which is the only
way to know. Two rules beat any interval:
- checkpoint after anything expensive or non-repeatable (a login, a payment, a page that took a minute to reach);
- keep progress in your own scope, so a re-entry skips what is already done.
- ADR 0001 — re-entry instead of a transparent Page proxy. The proxy was built, it passes every lint rule, and it was rejected anyway: it submits a form with the first field missing and logs nothing.
- ADR 0002 —
ctx.relaunch(): replacing the browser without re-entering the task. Why a library that is handed aPageneeds a door that re-entry does not open, and the four alternatives that were rejected. - Measurement 01 — what a relaunch costs.
See SECURITY.md. In short: a checkpoint contains session cookies.
It is held in memory for the length of the run and never written to disk, never
logged and never sent anywhere except back into the replacement browser. Log
lines and the wide event carry the checkpoint's hostname, never its URL,
because a Solari preview URL carries a bearer token in its query string. Any
text outlive did not write — your task's error message, an SDK failure — has
its URLs reduced to hostnames and its credential-shaped name=value pairs
blanked before it is logged. That is a net, not a proof: outlive cannot know
every shape a secret takes in your own messages. diagnostics: "full" turns
all of it off, deliberately and explicitly. If you persist a checkpoint
yourself, treat it as a credential.
The ~10-minute lifetime is not particular to this library's workload. An unrelated challenge submission, a computer-use benchmark that drives one browser through many generated app variants, reports in its own scorecard:
The session closed during seed 3 (~10–14 min of continuous use). For a larger matrix, rotate the browser session every ~4 variants (or 1 per variant) to avoid infra aborts.
—
reports/step-06-scorecard.md, itw-code/solari-cookbook @ae00916
Rotating by hand every four variants is the workaround outlive replaces: the death is detected on the connection and the task is re-entered, so the matrix does not have to know the platform's clock.
bun install
bun run lint # biome + oxlint (anti-slop) + the embedded test app
bun run typecheck
bun test src/ test-app/
bun run build && node scripts/dist-smoke.mjs
bun run test:e2e # live: waits ~14 min for a real session to die
bun run bench # live: 2 × 5 tasks of 12 minutesThe live scripts need SOLARI_API_KEY in .env and spend plan quota. Check
bun --env-file=.env scripts/cleanup-sandboxes.ts before and after: the plan
allows two concurrent sandboxes and the bench holds one of them.
MIT © Simon Doba