DEV Community

Dipankar Sarkar
Dipankar Sarkar

Posted on

AI agents that browse the web need a fleet of isolated browsers, here is a brokerless scheduler for it

Give an AI agent one browser and it is easy. Give a hundred agents their own browsers and you have an infrastructure problem.

Browser automation at scale has an awkward shape. Each browser wants real resources, a real filesystem, and real isolation, because you are running untrusted pages, scraping targets that fight back, or agent sessions you do not want sharing cookies. So you cannot just spin up a thousand threads. You end up managing containers, then managing the machines the containers run on, and then you are writing a scheduler.

Machineuse is that scheduler, built for exactly this workload. It is a Python platform that creates, schedules, and manages isolated browser instances across multiple worker nodes, with load balancing and a snapshot-based dormancy trick to reclaim resources from idle instances. This post covers how it places work, how dormancy works, and where the design constrains you.

The project tags itself for chromium, browser-automation, and mcp, so the agent-browser fleet is squarely the workload it has in mind. One thing to be precise about, though: machineuse schedules and isolates the containers. What runs inside, the browser and your automation or agent code, is yours. It is fleet infrastructure, not an agent framework. That separation is the point. You bring the agent, it brings the machines.

The core idea

The unit is an isolated browser instance running inside a systemd-nspawn container with dedicated resources. The platform's job is to decide which node runs each instance and to keep the fleet healthy.

Two design decisions define it.

First, the messaging is pure NNG, with no external broker. There is no Redis or RabbitMQ in the middle. Nodes talk to a control plane over NNG sockets directly. That is one fewer stateful system to run and one fewer thing to fall over.

Second, placement is intelligent rather than round-robin. The scheduler places instances based on node capabilities and current load, so a heavier node does not get handed work it cannot serve well.

How the architecture fits together

There are two roles. A control plane coordinates, and worker nodes run containers. You start a control plane bound to an NNG address, then point workers at it.

# Start the control plane
python -m machineuse.nodes.control_plane --bind tcp://*:5000

# Start worker nodes on different machines
python -m machineuse.nodes.agent worker-1 tcp://control-plane:5000
python -m machineuse.nodes.agent worker-2 tcp://control-plane:5000
Enter fullscreen mode Exit fullscreen mode

Storage is split by role, which is a sensible choice. Each node uses SQLite locally. The control plane can use PostgreSQL for shared metadata. DuckDB is used for analytics on the metrics side. So the local hot path stays embedded and simple, while shared state and analytics get the heavier stores only where they are actually needed.

Reliability comes from auto-healing: the platform detects failures and migrates instances off a bad node. Real-time metrics feed time-series analytics so you can see utilization across the cluster rather than guessing.

How snapshot dormancy works

This is the feature worth the price of admission. A browser instance you are not actively using still holds memory and CPU. Multiply that across a fleet and idle instances become the dominant cost.

Machineuse handles this with snapshot dormancy: it pauses an instance and takes a filesystem snapshot, freeing the live resources, then revives it from the snapshot when you need it again. So an instance you might need later does not have to sit resident. It goes dormant, gives back its resources, and comes back when called.

From the CLI that is two commands.

# Put an instance to sleep, reclaiming its resources
machineuse-cli dormant <id>

# Bring it back from its snapshot
machineuse-cli revive <id>
Enter fullscreen mode Exit fullscreen mode

The rest of the CLI is what you expect for lifecycle and fleet management.

# Instance lifecycle
machineuse-cli create --image ubuntu:22.04
machineuse-cli list --node worker-1
machineuse-cli delete <id>

# Cluster view
machineuse-cli nodes list
machineuse-cli cluster status
machineuse-cli metrics --node worker-1
Enter fullscreen mode Exit fullscreen mode

Using it from code

There is a Python client that talks to the cluster and a REST API on port 8000. The library path is the one most automation would use, since it hands back the scheduling result directly.

from machineuse.client import ClusterManager

# Connect to the distributed cluster
client = ClusterManager("tcp://control-plane:5000")

# Create an instance and let the scheduler place it
instance = client.create_instance(
    image="ubuntu:22.04",
    config={"memory_gb": 4, "cpu_cores": 2},
)
print(f"Instance {instance.id} scheduled on {instance.node_id}")

# Check fleet-wide utilization
status = client.get_cluster_status()
print(f"Cluster utilization: {status['utilization']}%")
Enter fullscreen mode Exit fullscreen mode

The REST surface mirrors this. A POST /v2/instances with an image and config creates an instance, GET /v2/instances lists them, and GET /health is the readiness check. Deployment is Docker Compose, with a single-node docker-compose.yml and a docker-compose.distributed.yml for control plane plus workers plus PostgreSQL.

Where it does not fit

systemd-nspawn means Linux, systemd, and root. The README is explicit: you need an Ubuntu or Debian system with systemd-nspawn support, Python 3.11+, and root or sudo access for container management. This is not going to run on macOS, and it is not going to run rootless. If your automation infrastructure is not systemd Linux you host yourself, this is the wrong tool.

There is a per-node ceiling. MACHINEUSE_MAX_INSTANCES defaults to 50 containers per node. That is a sensible default, and it also tells you the scaling model: you scale out by adding nodes, not by cramming a box. Plan capacity around instances-per-node times node-count, and remember each browser is a real resource consumer.

Dormancy is a trade, not free capacity. Snapshotting to disk and reviving takes time and disk space. It is a win when instances are idle long enough to justify the pause and restore cost. For instances you cycle rapidly, the snapshot overhead may cost more than it saves. It shines for a large pool of intermittently-used sessions, not for high-churn throwaway instances.

Brokerless NNG puts coordination on you. No external broker is a real operational win, but the control plane is then the coordination point you must keep available. It uses PostgreSQL for shared metadata, so your durability and HA story for the fleet is your durability and HA story for that database and that control-plane process.

Takeaways

Machineuse is a focused answer to one real problem: running many isolated browsers across many machines without hand-rolling the scheduler and without standing up a message broker.

The idea worth stealing is snapshot dormancy. Treating an idle heavy instance as something you pause to a filesystem snapshot and revive on demand is a clean way to decouple "instances that exist" from "resources currently spent." It turns idle capacity from a cost into a snapshot on disk.

Repo: https://github.com/dotcommoners/machineuse

If you run browser automation at any real scale, stand up the single-node Compose stack, create a handful of instances, and measure your own dormant-to-revive time. Real revive latency on your storage is the number that decides whether dormancy pays off, and it is exactly the kind of issue worth filing.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Isolated browser fleets feel boring until you run real agents. Then cookies, filesystem state, crashes, and hostile pages become infrastructure concerns. I like the brokerless angle here because scheduling browser sessions is less about raw throughput and more about predictable isolation under messy workloads.