all posts

MicroVMs for Real-Time Collaborative App Backends

Ajay Kumar··8 min read

Real-time collaborative apps — Figma-style canvases, Notion-style docs, collaborative coding rooms, multiplayer whiteboards — all share the same backend shape: a live "room" that a handful of connected clients push edits into, a server that merges those edits (CRDT or OT), and a fan-out that broadcasts the merged state back to everyone. The hard part isn't the sync algorithm; it's the operational shape around it. Rooms are bursty and long-tailed: a few are white-hot right now, thousands are idle, and any one of them can wake up the instant someone reopens a tab. This post is about giving each live room its own Firecracker microVM — one isolated VM per document or session — and why that turns out to be a surprisingly clean fit.

I'm Ajay, I built PandaStack. The pattern here is: create a sandbox per room running your sync server, hibernate it when the last client disconnects, and auto-wake it on reconnect. Each sandbox is its own Firecracker microVM with its own guest kernel, so one room's runaway state can't touch another's — and the snapshot-restore create path (p50 179ms) makes per-room VMs cheap enough to actually do this.

Why isolate at the room boundary?

The instinct for a collaborative backend is to run one big process — a fleet of sync servers, each holding thousands of rooms in memory, sharded by document ID. That works right up until it doesn't. The room is the natural unit of blast radius: everyone in a room already shares state by definition, and nobody outside it should. When a single process holds many rooms, a memory leak in one document's history, a pathological CRDT merge, or a user-supplied formula that spins forever degrades every room on that process. One user's runaway formula shouldn't freeze everyone else's cursors — but in a shared process, it does exactly that.

A room-per-microVM design makes the room boundary a hardware boundary. Each room's sync server, its in-memory document, its history, and any code it executes live inside one Firecracker guest with its own kernel, memory, and filesystem. A room that OOMs, wedges, or gets exploited takes down exactly one room — its own. That's the same property AWS Lambda relies on to run untrusted functions from thousands of customers on shared fleets, applied to the room as the tenant.

The mental model: one microVM per live room (or per document session), not one server for all rooms. The isolation boundary is the VM, so it's only meaningful if a VM maps to a single trust domain — one document, one session, one set of collaborators who already share that state.

The room lifecycle: create, hibernate, wake

A collaborative room has a distinctive lifecycle: it's created when the first client connects, active while people are editing, idle the moment the last tab closes, and possibly revived hours or days later. Keeping a full VM running for a document nobody is looking at is waste; cold-starting one from scratch every reconnect is too slow. The middle path is snapshot-and-wake. When the last client disconnects, call `hibernate()` — it snapshots the guest's memory and disk and stops the VM, so the merged CRDT state and open connections' bookkeeping are frozen exactly as they were. The next request auto-wakes it, restoring that memory image rather than replaying an edit log from cold.

from pandastack import Sandbox

def ensure_room(room_id: str, sync_server_code: str) -> Sandbox:
    """Get (or spin up) the microVM backing one collaborative room."""
    # One VM per room. Persistent so it survives past a single request;
    # ttl_seconds is a backstop in case a room is abandoned mid-session.
    sbx = Sandbox.create(
        template="base",
        persistent=True,
        ttl_seconds=86_400,
        metadata={"room_id": room_id},
    )

    # Ship the CRDT/OT sync server into the guest and start it detached.
    sbx.filesystem.write("/workspace/sync_server.js", sync_server_code)
    sbx.exec(
        "setsid node /workspace/sync_server.js "
        "> /var/log/room.log 2>&1 < /dev/null &",
        timeout_seconds=15,
    )
    return sbx


def on_last_client_left(sbx: Sandbox) -> None:
    # Snapshot memory + disk, stop the VM. Merged doc state is frozen in place.
    sbx.hibernate()


def on_client_reconnect(sbx: Sandbox) -> None:
    # Next request against a hibernated sandbox auto-wakes it from the
    # snapshot -- no cold boot, no replaying the edit log from scratch.
    result = sbx.exec("curl -sf localhost:1234/healthz", timeout_seconds=10)
    assert result.exit_code == 0, "sync server did not come back up"

The economics matter here. An idle hibernated room costs storage for its snapshot, not RAM on a running host — so a long tail of thousands of dormant documents doesn't pin memory the way thousands of live processes would. And waking is a restore, not a boot: the create/restore fast path is p50 179ms (p99 around 203ms), with the snapshot-restore step itself around 49ms, because every restore pages a baked memory image back in rather than cold-booting (which is roughly 3s, and only happens the very first time a template has no snapshot yet).

Hibernate freezes wall-clock time inside the guest along with everything else — a room woken days later resumes with its clock at snapshot time until it re-syncs. If your sync server pins TLS-dependent upstreams or timestamps edits from the guest clock, sync time on wake before trusting it. Treat the woken document state as authoritative; treat the guest's sense of "now" as stale until corrected.

Fanning out to many concurrent rooms

Fan-out inside a room — broadcasting one merged update to every connected client — is your sync server's job, and it's cheap because a room has a handful of participants, not millions. The interesting fan-out is across rooms: how many concurrent live rooms can one host carry? On PandaStack each agent pre-allocates 16,384 /30 subnets, so networking isn't the ceiling — memory and CPU are. Each active room is a live guest holding its document in RAM; the binding constraint is how much guest memory your hosts have, which is exactly why hibernating idle rooms is load-bearing rather than a nicety. Hot rooms stay resident; cold rooms fall back to snapshots and free their memory for the next hot one.

For rooms that spawn from a common starting point — say every new design file begins from a template document, or every coding room starts from the same repo checkout — fork instead of create-from-scratch. A same-host fork is 400–750ms and shares the parent's memory copy-on-write, so a hundred rooms branched off one pre-warmed base share pages until they diverge. Cross-host forks (1.2–3.5s) let you spread rooms across the fleet when one host fills up.

Shared process vs container-per-room vs microVM-per-room

There are three common ways to host per-room collaborative backends. They trade off density, isolation, and blast radius differently:

  • Shared process, many rooms — Density: highest (rooms are just map entries). Isolation: none; one wedged room or leaked buffer degrades every room on the process, and any per-room code runs in your address space.
  • Container per room — Density: high; startup: fast. Isolation: namespaces + cgroups on a shared host kernel, so a kernel bug or container escape reaches neighbors — acceptable for trusted first-party sync code, risky for untrusted per-room plugins.
  • MicroVM per room — Density: lower per host, but idle rooms hibernate to snapshots and free RAM. Isolation: hardware-virtualized guest kernel per room, so a runaway or exploited room is contained to itself; startup is a snapshot restore (p50 179ms), not a cold boot.

The right choice depends on trust. If every room runs only your own vetted sync server and rooms never execute user-supplied logic, a sharded shared process or container-per-room is simpler and denser — don't reach for a VM per room to run code you wrote and trust. The calculus flips the moment rooms run untrusted code.

Running untrusted per-room code: plugins and formulas

Modern collaborative apps aren't just moving cursors around — they run user-authored logic inside the room. Spreadsheet formulas, Figma-style plugins, Notion database formulas, collaborative notebooks executing cells, custom automation triggers. That logic comes from your users, which means it's untrusted by definition, and it runs live while other people are connected. In a shared process, a malicious or merely infinite formula is a denial of service against everyone in that room's neighborhood. With a microVM per room, arbitrary per-room code executes inside that room's own guest kernel — a `while true`, a fork bomb, or a 40 GB allocation is contained to the one VM whose users authored it.

The pattern is the same as the room's own lifecycle: write the untrusted snippet into the guest, exec it with a hard timeout as your circuit breaker, and read the result back through the filesystem. The timeout is non-negotiable — user-written formulas loop far more often than you'd like.

# Evaluate an untrusted per-room formula inside that room's own microVM.
# The --timeout maps to exec's timeout_seconds: your circuit breaker
# against a formula that decides to compute pi forever.

cat > /tmp/formula.py <<'EOF'
# user-authored cell -- do NOT trust this
result = sum(i * i for i in range(1000))
print(result)
EOF

pandastack fs write   <room-sandbox-id> /workspace/formula.py --from /tmp/formula.py
pandastack exec       <room-sandbox-id> "python3 /workspace/formula.py" --timeout 5

# If it hangs, the timeout kills it -- one room's cursor freezes, not the fleet's.

Because each room is its own VM, you never mix two documents' untrusted code in one guest. That's the whole point: the isolation boundary is the room, the plugin author is contained to the room they wrote for, and a formula that misbehaves is a problem for exactly the people who opened that document.

When a VM per room is the wrong call

Per-room microVMs are not free, and they're not always the answer. If your rooms are tiny, ultra-high-churn, and run only trusted first-party sync code — think ephemeral presence channels or short-lived cursor-only sessions — the per-VM overhead and the create latency may outweigh the isolation you don't actually need; a sharded process is leaner. The design earns its keep when rooms are meaningfully stateful, long-lived with a long idle tail, or execute untrusted per-room logic. In that regime you get true per-tenant isolation, idle rooms that cost snapshots instead of RAM, and sub-200ms wake-on-reconnect — the convenience of one-process-per-room with the blast radius of a real hypervisor.

One honest caveat on competitors: platforms like Cloudflare Durable Objects, Liveblocks, PartyKit, and others also give you a per-room server abstraction, each with different isolation and pricing models — verify their isolation guarantees against their own docs rather than assuming they match a per-VM boundary. The distinguishing property of the microVM approach is the hardware isolation and the ability to run genuinely untrusted per-room code, not just trusted sync logic.

Frequently asked questions

Why give each collaborative room its own microVM instead of one shared server?

The room is the natural blast-radius boundary — everyone in a room already shares state, and nobody outside it should. In a shared process, one room's memory leak, pathological CRDT merge, or runaway user formula degrades every room on that process. A microVM per room makes that boundary a hardware boundary: each room's sync server, document, and any code it runs live in a Firecracker guest with its own kernel, so a room that OOMs or gets exploited takes down only itself.

How do you avoid paying for idle collaborative rooms?

Hibernate them. When the last client disconnects, call hibernate() — it snapshots the guest's memory and disk and stops the VM, so an idle room costs snapshot storage rather than RAM on a running host. The next request auto-wakes it by restoring that memory image, which is a snapshot restore (create fast path is p50 179ms, restore step ~49ms) rather than a ~3s cold boot or replaying an edit log from scratch.

How many concurrent rooms can one host handle?

Networking isn't the ceiling — each PandaStack agent pre-allocates 16,384 /30 subnets. The binding constraint is guest memory and CPU, since each active room is a live VM holding its document in RAM. That's why hibernating idle rooms is load-bearing: hot rooms stay resident, cold rooms fall back to snapshots and free their memory. For rooms branched from a common starting point, forking (same-host 400–750ms, cross-host 1.2–3.5s) shares memory copy-on-write until they diverge.

Can I run untrusted per-room plugins or formulas safely?

Yes — that's the strongest reason for a VM per room. Spreadsheet formulas, plugins, and notebook cells are user-authored and therefore untrusted, and they run live while others are connected. With a microVM per room, that code executes inside the room's own guest kernel: write the snippet into the guest, exec it with a hard timeout as your circuit breaker, and read the result back. A malicious or infinite formula is contained to the one room whose users authored it, not the whole fleet.

When is a microVM per room the wrong choice?

When rooms are tiny, ultra-high-churn, and run only trusted first-party code — for example ephemeral presence or cursor-only channels — the per-VM overhead and create latency can outweigh isolation you don't need, and a sharded shared process is leaner. Per-room microVMs earn their keep when rooms are meaningfully stateful, long-lived with a long idle tail, or execute untrusted per-room logic.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.