A browser is a persistent identity: a Chrome profile (cookies, sign-ins, local storage) that lives on the engine's disk, plus the container that runs it when needed. The record lives in MongoDB; the container comes and goes.
The record
type TBrowser = {
id: string;
userId: string | null;
key: string | null; // who owns the profile, one browser per user and key
config: TBrowserConfig;
state: "running" | "stopped"; // desired state; reconcile makes Docker match it
live: { version: number }; // bumping it revokes every live link
createdAt: Date;
updatedAt: Date;
};
type TBrowserConfig = {
engine: { name: "patchright"; channel: "chrome" | "chromium" };
resource: { memoryMb: number; cpu: number }; // default 1200 MB, 1 CPU
display: { width: number; height: number }; // default 1366 × 768
proxy: { server: string; username: string | null; password: string | null } | null;
locale: string; // default "en-US"
timezone: string; // default "UTC"
flags: string[];
};
Browsers by id, workers by owner
Each browser has a name in a run: x, threads, facebook and shopee for the platform nodes (their default), default for the rest, or what a node's Browser setting says. mooflair's run payload sends, per name, browser.name.<name> = { id, account }: the id of the Browser the campaign (or the flow, in tests) picked for it, and the account acting there ({ id, platform, userId, limit: { max } }).
| What | Found by | Folder in the library |
|---|---|---|
| A browser (pages) | its id: mooflair's Browser and this record share it (made with POST /browser) | library/browser/<id>/{profile,download} |
| A worker (no page) | key <campaign|flow>:<owner id>:worker | library/<kind>/<owner id>/worker |
A browser is signed in once, in its profile, and every run that uses it works in it at once, whichever campaign or test it belongs to (AppBrowser is shared: it tracks the apps using it and its runs). Its proxy is on the record (config.proxy, from the Browser). Nobody claims it:
- A window per run: every lane of a run works in a window of its own (a page a node opens with
tabs.open, like Open in new tab, is a window of its own too) (opened through CDPTarget.createTargetwithnewWindow: a background tab gets throttled, a window doesn't), closed at the run's end. The browser's first window stays as its home. At most 3 runs at once per browser; a fourth waits (Waiting for a free window). - Turns: effect steps take turns per browser (two runs never act on the outside world at the same moment), and so do Sign in steps (the second finds it signed in, one wait for you at most).
- Identity check: before an effect step on an account, the agent reads the user id cookie (
twid,ds_user_id,c_user,SPC_U) and fails the step if it isn't the account's. - Who's in it: the browser record lists its runs (
run: [{ id, label }], a campaign's or flow's name) andfront, the run whose window is on top: the agent reports it (afrontrun event) each time a window opens or one waiting for you is raised, so Live view shows names the right campaign. The campaign's Browsers tab shows Also running here and Live view shows (whose window is in front). A campaign's dashboard token lists its browsers, so it may watch them live; a new window never covers one that waits for you. - At the workspace's limit (its admin-set RAM and CPU, or the plan's browsers at once), a browser of the workspace that no run uses and nobody looked at for 2 minutes gives its room (it stops, its profile stays), so an idle browser never blocks a run. See Resources and limits.
- An app that goes idle lets go of the browsers it used; a browser no app uses leaves memory, and stops after the idle time like any other.
A browser opens the first time a page node uses its name; a page node with no browser of its own runs in the one used last, else the first name the flow uses, else default. Nodes that need no page run elsewhere (see The worker). A campaign's row action (Delete reply) is a priority run in its own tab beside the current run. Logs carry the account of the browser they happened in (accountId), so a campaign's Logs filter by account.
Browsers are mooflair's: deleting one (DELETE /browser/:id) removes the record, container, network and profile. Deleting a campaign or flow (DELETE /app/<campaign|flow>/<id>) removes its worker, its runs (with ?run=1) and its library folder; mooflair deletes its browsers nobody else uses.
browserOpen (controllers/browser/open.ts) opens a browser by id (or the owner's worker, made on first use): one not running yet needs room first (Resources and limits); then it's marked running at the current size and activeAt is set. Open browser in Studio, a campaign's Browsers tab Live and Operate › Browsers Live start it with POST /browser/:id/start, which opens it the same way (429 at the workspace's limit, 503 when the engine is full).
Containers and reconcile
browserReconcile makes Docker match MongoDB. It runs on boot, every 15 seconds, and right after a state change.
- Container name
browser-<id>, imagebrowser-<engine.name>(browser-patchright). - Everything the container starts with (config, env) is hashed; a changed config or a rebuilt image recreates the container, once no run is working in it (the browser record lists none; a worker lists its runs too): busy, it keeps the old one until its runs end. A restart asked for (
recreate) doesn't wait. A removed container takes its agent with it, and the run it was working on fails. - RAM and CPU aren't in the hash: a new size is applied to the running container (
POST /containers/:id/update,MemorySwap=Memory, so no swap past it). RAM going down waits until no run is in it, so Chrome isn't killed mid-run. Growing (RAM or CPU) needs room in the workspace's lease first: refused, or mooflair not answering, only what goes down applies and the next pass (15 seconds) tries again. Shared memory (shmMb) is in the hash: it changes when the container is next recreated. - A container the kernel killed for going past its RAM (
State.OOMKilled) fails its runs with "The browser ran out of memory (its limit is N MB)". - Each browser gets its own Docker network,
zz-browser-<id>, shared only with the engine and NATS (aliasednats). Thezz-keeps it sorting after the engine's own network: Docker gives a container its default route from the network that sorts first, so a name sorting beforeengine-networkwould move the engine's default route onto the browser network the moment it joins, and every connection open to the engine from outside would drop. Reconcile removes the network of a browser that is stopped or gone (the engine and NATS sit on every network, so nothing else frees it, and each one holds a Docker address pool: run out, and no new browser can start). A network that fails to create is logged. - Profile and downloads live in
library/browser/<id>/{profile,download}, owned by uid 1000. - Orphans (a container whose record is gone) are removed with their network.
POST /browser/:id/lifecycle/restartdrops the container and creates a fresh one.
The worker
Nodes that don't act on a web page (AI, data, flow, triggers, approval, notify; a node definition's page: false, a script node whose code doesn't use page or tabs) never open Chrome for themselves:
- In a run that already uses a Chrome browser, they run there (the browser used last).
- Otherwise they run in the owner's worker: the same agent image started without Chrome or a display (
BROWSER_MODE=worker), the worker size from mooflair's Configuration › Resources (default 256 MB, half a CPU, 64 MB shared memory), key<kind>:<owner id>:worker, folderlibrary/<kind>/<id>/worker. It opens on the first such node and stops afterWORKER_IDLE_MS(default 5 minutes) without a run. - So a webhook, schedule or chat flow of AI and data nodes never starts Chrome. When a page node comes later, its browser opens and the nodes after it run there.
- In the worker,
ctx.pageandctx.tabs.openthrow " acts on a web page, but it ran in the worker (no Chrome)". - The worker has no screen: viewers never get a tab for it, the run's cursor stays on its last Chrome browser, and
POST /browser/:id/liverefuses it. - Workers need room like browsers (this host's, and the workspace's worker limit) but don't count toward the plan's browsers at once. A run in a worker is listed on its record, so it's never stopped or recreated under the run. The name
workeris kept for it (a node's Browser setting can't use it). - Like the owner's browsers, it has the owner's at
/file, so a file a worker node wrote is there for a page node in Chrome, and back. - Worker time is reported apart (
worker.second) and counts toward browser hours atbilling.meter.worker.weight(default 0.25).
Resources and limits
Every container is held to its size by the kernel (cgroups): RAM is a hard cap (past it, the kernel kills a process in it: see the out-of-memory message above), CPU is a quota (past it, it only slows down).
| What | Where it's set | Default |
|---|---|---|
| Each browser's RAM, CPU and shared memory | mooflair Admin › Configuration › Resources (resource.browser) | 1200 MB, 1 CPU, 1024 MB shared memory (part of the RAM) |
| Each worker's RAM and CPU | resource.worker | 256 MB, 0.5 CPU |
| What each engine's host keeps for itself (engine, MongoDB, NATS) | resource.engine.reserve | 2048 MB, 1 CPU |
| What a workspace's browsers and workers may use at once, across every engine | mooflair Admin › Workspaces › the workspace › Limit (limit.browser, limit.worker) | no cap |
Starting a container needs room twice:
- This host: the RAM and CPU of every running and draining container, plus this one, must fit in the host's (Docker's
MemTotalandNCPU) less the reserve. Else503 The engine is at capacity, and a run waits its turn up to 10 minutes. Set the engine's capacity in mooflair (Admin › Engine) to about how many browsers fit: mooflair spreads workspaces across engines by it. - The workspace's lease at mooflair (
POST /api/internal/lease, one document per workspace,entryper container): one atomic update checks the workspace's limit and, for browsers, the plan's browsers at once, across every engine. Else429with the reason, and a run waits as long as it takes (the time doesn't count toward its time limit); the same when mooflair doesn't answer. Growing a running container reserves its whole new size the same way. A stopped container releases its room; every usage report replaces this engine's entries with what each container really holds (read from Docker; a reservation from the last minute stays, it may be starting), so a lost release is fixed within a minute. An entry not reported for 3 minutes (its engine gone or unreachable) no longer counts and is dropped, so a removed engine never blocks a workspace. Deleting a workspace deletes its lease.
Lowering a limit (or the sizes) takes effect at once: mooflair asks each engine of the workspace, one after another, to report now (POST /usage). Its answer says how much each workspace over its limit frees (free). The engine frees idle ones first, then the newest:
- idle (no run in it): stopped at once, profile kept;
- busy:
draining. No new run gets it (a run already in it keeps it), and it stops once its runs end (reconcile, within 15 seconds). It still holds its room until then, so new ones wait.
Nothing is cut mid-run. A run with no time limit never ends, so its browser never drains: the workspace's Limit card lists draining ones with Stop now (its runs end).
Idle stop
A running browser with no active run for BROWSER_IDLE_MS (default 10 minutes, measured from activeAt) is stopped. A browser with a run in it is never stopped, even while the run sleeps in a Wait node. The next run or Open browser starts it again, with the same profile.
Turning a campaign off with Also close its browsers (POST /campaign/:id/stop) stops its browsers right away instead, once its runs have ended.
Inside the container
docker/patchright/:
- KasmVNC on display
:1at the browser's size, web client on port6901. Two KasmVNC users:control(-rw) andview(-r; without read rights the view stays black). Passwords come from the engine. - Chrome launched by Patchright
launchPersistentContextwith the profile folder. Channelchromeon amd64,chromiumon Apple Silicon (no Google Chrome for linux/arm64):BROWSER_CHANNEL. - The agent (
agent/patchright): connects to NATS, answersbrowser.<id>.ping, runs flows, publishes events. - A Chrome policy file (
policy.json): WebRTC only through the proxy, no video autoplay, and Chrome's background services off (component updates, translate, sync, background mode, metrics). The launch flags add the same (--autoplay-policy=user-gesture-required,--disable-component-update,--disable-background-networking, Optimization Guide downloads, Translate and Media Router off). - Blocked requests: every page skips video and stream files (
*.mp4,*.m3u8,*.m4s,*.webm,video.twimg.com), web fonts (*.woff*,*.ttf,*.otf) and common trackers and ads (Google Analytics, Tag Manager, DoubleClick, Facebook pixel, Hotjar, Clarity…). Chrome blocks them itself (CDPNetwork.setBlockedURLs), so its cache stays on. Images still load. The list isblockedinagent/patchright/index.ts; a change needsnpm run build:browser.
Events and stats
-
GET /browser/:id/event(server): Server-Sent Events of everything onbrowser.<id>.event, plus stats every 5 seconds.{ "type": "agent", "status": "ready", "at": 1793167566319 } { "type": "live", "status": "open", "access": "control", "at": 1793167566319 } { "type": "stats", "stats": { "cpu": { "percent": 12.5 }, "memory": { "usedMb": 640, "maxMb": 1200 } }, "at": 1793167566319 } -
GET /browser/:id/stats(server): one stats sample. -
If the agent exits during a run, the engine fails the run with
The browser stopped during the run.
FAQ
How do I reset a browser's sign-in?
Open Operate › Browsers, press Live, tick Take control and sign out there. To start from nothing, delete the browser (DELETE /browser/:id, server): the record, container, network and its library folder (profile and downloads) are removed. The next run creates a fresh one.
Does rebuilding the image stop my running flows?
No: a browser with runs keeps its container (and old agent) until they end; the next reconcile after that recreates it on the new image. An engine restart still ends every run (they live in its memory); campaign runs resume from where they were.