all posts

Runnable Code Playgrounds in Your Docs, Safely

Ajay Kumar··10 min read

Every developer-tools company eventually files the same ticket: "put a Run button on the docs." It's a genuinely great idea. A sample the reader can execute and edit converts far better than one they have to copy into a terminal, and it removes the worst step of any onboarding funnel — install our thing before you know whether you want our thing.

Then you build it and discover you have not shipped a docs feature. You have shipped a free, unauthenticated, publicly-addressable compute API and called it developer experience. This post covers the two hard parts — the anonymous-visitor threat model and the latency budget — why browser-only sandboxes hit a real ceiling, and what a per-run Firecracker microVM architecture looks like. I'm Ajay, I built PandaStack, so I'll be direct about the trade-offs, including when to run nothing on a server at all.

The anonymous-visitor threat model

Every other untrusted-code system you've built has an account behind it. A CI runner belongs to a repo owner; a code interpreter belongs to a customer whose card you can charge and whose key you can revoke. A docs playground has none of that, and almost every control you'd reach for quietly depends on it.

There is nobody to ban

The person hitting Run has no account, no email, and no payment method. Your standard abuse response — suspend the tenant — has no object to act on. What you have is an IP address, shared by an entire university, rotated by a mobile carrier, and rented by the hour from any residential-proxy provider for less than the compute they're stealing. There's no throttle on retries either: a real user runs a snippet ten times while learning; a script runs it ten thousand times while you sleep. And the traffic is spiky — forty runs an hour for six months, then somebody posts it to Hacker News and it's forty a second.

Crypto miners, egress abuse, and your own metadata endpoint

Free anonymous compute has a very motivated existing user base. Within days of launch you'll see, in rough order of creativity: cryptocurrency miners (your CPU, their wallet), long-running clients used as free proxies or scrapers, DDoS participation where your egress attacks someone else, and outbound spam. None of these need an exploit. They're just your feature, used enthusiastically.

The subtler one is the request that never leaves your network. A snippet fetching an internal address is doing SSRF from inside your infrastructure, and the classic target is the cloud metadata endpoint at 169.254.169.254, which on a misconfigured host will hand a curl command your instance's IAM credentials. Your internal admin API and the database that's only firewalled from the public internet are equally reachable. The playground runtime is a machine inside your VPC that strangers type commands into.

Somebody will paste a fork bomb during launch week

You can set your watch by this. The week you launch, somebody pastes a fork bomb into the editor — the shell one-liner, or `while True: os.fork()`, or a loop that appends to a list until the OOM killer picks a victim. Half are hostile and half are people testing what your sandbox does, which from the host's perspective is the same event. Next to them sit the boring failures: an infinite loop with no output, and a fifty-thousand-line stdout stream that kills your API process when you buffer it into a JSON response.

The three threats compose. A single anonymous request can be an unbounded resource consumer, a network abuse vector, and an SSRF probe at once — and unlike a signed-in tenant, the sender bears no cost for retrying forever. Design so the worst possible run is bounded, disposable, and cheap, because you cannot prevent the run itself.

The latency budget: 4 seconds is a dead feature

Here's the constraint that kills most careful designs. The reader's mental model of the Run button is a REPL, not a CI job. If pressing Run takes four seconds to print `hello world`, the feature is dead — not broken, worse: it reads as a slow product. People generalize wildly from a docs playground's responsiveness to your platform's, which is unfair and completely real.

That budget is what makes "one fresh VM per run" sound impossible, and why so many teams end up with a shared worker pool they then defend forever. The historical numbers were bad: a full VM boot is seconds, and a container cold start with an image pull isn't obviously better. Snapshot-restore changes the arithmetic. On PandaStack a sandbox isn't booted, it's restored from a baked snapshot on demand — p50 179ms, p99 203ms end to end, of which the restore step itself is about 49ms; a first-ever cold boot of a template is roughly 3 seconds, and every create after that takes the snapshot path.

Budget it out loud: ~180ms for the VM, tens of milliseconds to write the snippet in, then the snippet's own runtime, then teardown you can do after the response is already streaming. The dominant term is the visitor's code, which is exactly where you want it. A warm pool is the other way to hit the budget — but you pay for it while it's idle, and a docs playground is idle almost all of the time.

The browser-side options and where they stop

The obvious move is to run nothing on a server: ship the runtime to the reader's tab and the whole threat model evaporates, because the blast radius is one browser tab that already runs untrusted code for a living. That's an excellent answer for some docs and a hard ceiling for others.

WASM in the browser: real sandbox, narrow world

Compiling a language runtime to WebAssembly — Pyodide, a WASM build of SQLite or DuckDB — gives you genuine isolation for free, in the same sandbox the browser already uses for every ad on the internet, at zero cost per run. The limits are structural, and they're all one limit wearing different hats: WASM has no operating system underneath it. No native dependencies unless somebody compiled that specific library to WASM (for the scientific and cryptographic tails, often "no"), no real sockets — network is whatever the host page proxies through fetch, subject to CORS — no fork or subprocess model, no threads without cross-origin isolation headers, and no shell. Startup isn't free either: the runtime download can run to tens of megabytes before the first line executes.

WebContainers: excellent, and Node-shaped

WebContainers run a Node-like environment in the browser via WASM, and for JavaScript documentation they're genuinely delightful — a package install and a dev server in a tab. The ceiling is the shape of the thing: a Node ecosystem, in a browser, on the user's machine. If your docs need Python, Go, a Postgres connection, a compiled native extension, or a snippet that talks to your production API over a normal socket, you're outside the model rather than configuring it. It's also browser-bound operationally — you can't run the same snippet server-side in CI to check your docs still work.

When the browser is the right answer — honestly

If your docs are a JavaScript library, a CSS tool, a client-side SDK, a regex explainer, or a pure-function API, do not build any of the server-side machinery in this post. Ship WASM or a WebContainer and spend the quarter elsewhere: per-run cost is zero, there's no abuse surface worth staffing, no rate limiter, no incident channel. I've watched teams reach for microVMs because the isolation argument is intellectually satisfying, when what they needed was a 3MB WASM build of a parser.

The line is sharp: the moment the honest version of your sample needs a native dependency, a subprocess, a real outbound connection, a language your readers' browsers don't have, or a call to your production API the way real code makes it, the browser stops being a sandbox and becomes a constraint you write around. That's where the server-side model earns its complexity.

Browser WASM vs. server-side microVM

Two honest models for a Run button, on the axes that actually decide it. Verify any competing runtime's current behavior against its own documentation.

  • Capability — Browser WASM: whatever's been compiled to WASM; no shell, no process tree, no OS underneath. Server microVM: a full Linux guest with a kernel, shell, filesystem, and any binary you can install.
  • Native dependencies — Browser WASM: only if someone ported that exact library. Server microVM: pip, npm, apt, cargo — and bake the heavy ones into the template so the run costs nothing.
  • Network — Browser WASM: no raw sockets; outbound is whatever the page proxies via fetch, bounded by CORS. Server microVM: real sockets and DNS — a feature for demos against your live API, a liability you fence with egress policy.
  • Abuse containment — Browser WASM: perfect by construction; a miner burns the visitor's own CPU. Server microVM: your CPU, so you need per-VM caps, timeouts, egress rules, and rate limits — enforced at the hypervisor and network layer, not by convention.
  • Latency and cost — Browser WASM: a multi-megabyte runtime download up front, then instant runs at zero marginal cost. Server microVM: on PandaStack a fresh VM at p50 179ms plus the snippet's runtime, at a small per-run cost you can cap.
  • Fidelity — Browser WASM: an approximation of your runtime, free to drift. Server microVM: the same environment your platform runs, so a passing snippet is evidence and the samples can run in CI.

The per-run microVM architecture

The shape is deliberately boring, and every step exists because of something in the threat model above. One anonymous run maps to one throwaway VM: created, used, destroyed, never shown to a second visitor.

  1. Gate the request before it costs anything: rate-limit by IP and session cookie, cap snippet size, cap global in-flight runs, 429 the rest. The cheapest run is the one you never start.
  2. Create a sandbox from a pre-baked template with the runtime already inside. No pip install in the request path — the snapshot is the install.
  3. Write the snippet into the guest filesystem instead of interpolating it into a shell command. Editor input plus shell quoting is its own vulnerability class.
  4. Exec with a hard host-side wall-clock timeout, plus a second timeout inside the guest as defense in depth. The host-side one is the one that holds — it doesn't need the guest to cooperate.
  5. Stream stdout back over SSE as it's produced, with a byte cap. Streaming makes a slow snippet feel alive instead of hung; the cap stops a 500MB print loop from becoming your outage.
  6. Kill the VM. Not "return it to a pool," not "reset it" — destroy it. Set a TTL as a backstop for when your own handler crashes first.

Here's the core of it in the Python SDK. The handler is short, which is the point — the guarantees come from the boundary, not from clever code in the request path.

from pandastack import Sandbox

MAX_OUTPUT = 32 * 1024  # bytes of stdout we're willing to ship to a browser


def run_snippet(code: str, run_id: str) -> dict:
    """One anonymous visitor pressed Run. Give them a VM, then take it back."""
    if len(code) > 20_000:
        raise ValueError("snippet too large")

    # Fresh VM per run, restored from a baked snapshot (~180ms), never reused.
    # ttl_seconds is the backstop: if this process dies mid-request, the VM
    # still reaps itself instead of mining somebody's altcoin until Friday.
    with Sandbox.create(
        template="code-interpreter",
        ttl_seconds=120,
        metadata={"source": "docs-playground", "run": run_id},
    ) as sbx:
        # Write the snippet to a file -- never interpolate visitor input
        # into a shell string. Quoting bugs are a vulnerability class.
        sbx.filesystem.write("/workspace/snippet.py", code.encode())

        # Hard wall-clock cap enforced host-side. A fork bomb, an infinite
        # loop, and a miner all end the same way here: the VM dies.
        run = sbx.exec(
            "cd /workspace && python3 snippet.py",
            timeout_seconds=10,
        )

        return {
            "exit_code": run.exit_code,
            "stdout": run.stdout[:MAX_OUTPUT],
            "stderr": run.stderr[:MAX_OUTPUT],
            "truncated": len(run.stdout) > MAX_OUTPUT,
        }
    # VM destroyed on block exit: memory, disk, and every process in it.

Note what's absent: no cleanup routine, no "reset the interpreter state" step, no scrubbing of temp files, no wondering whether the last visitor left a process running — the machine that ran their code no longer exists. Disposability does most of the security work, and it's only affordable because creating the replacement takes about as long as a page transition.

Caps, rate limits, and the fork bomb

The gate in front of the VM matters as much as the VM. In TypeScript, a route handler that refuses cheaply, runs with an explicit kill in a `finally`, and trusts the client for nothing but the snippet text:

import { Sandbox } from "@pandastack/sdk";

const MAX_CONCURRENT = 40;        // global in-flight cap for the whole feature
const PER_IP_PER_MINUTE = 6;      // a human learning, not a script looping
let inFlight = 0;

export async function POST(req: Request) {
  const ip = req.headers.get("cf-connecting-ip") ?? "unknown";
  const { code } = await req.json();

  // 1. Refuse before spending anything. Cheapest run = the one not started.
  if (code.length > 20_000) return json({ error: "snippet too large" }, 413);
  if (!(await rateLimit(ip, PER_IP_PER_MINUTE))) {
    return json({ error: "slow down — this is a docs page, not a compute grid" }, 429);
  }
  if (inFlight >= MAX_CONCURRENT) {
    return json({ error: "playground busy, try again in a moment" }, 503);
  }

  inFlight++;
  const sbx = await Sandbox.create({
    template: "code-interpreter",
    ttlSeconds: 120,
    metadata: { source: "docs-playground", ip },
  });
  try {
    await sbx.filesystem.write("/workspace/snippet.py", code);
    const r = await sbx.exec("cd /workspace && python3 snippet.py", {
      timeoutSeconds: 10,
    });
    return json({
      exitCode: r.exitCode,
      stdout: r.stdout.slice(0, 32_768),
      stderr: r.stderr.slice(0, 32_768),
    });
  } finally {
    await sbx.kill();   // explicit, always, even on throw
    inFlight--;
  }
}

Now the launch-week fork bomb. A visitor pastes something whose entire purpose is to consume every process slot and every byte of RAM on the machine it runs on:

# What lands in your editor box on day three. Pick your flavor.
:(){ :|:& };:                      # classic shell fork bomb
python3 -c 'import os\nwhile True: os.fork()'
python3 -c 'x=[]\nwhile True: x.append("a"*10**6)'   # eat all RAM

# In a shared worker process, any of these is an incident: the box wedges,
# every other request queues behind it, and you page somebody.
#
# In a per-run microVM it is a bounded, boring event:
#   - the guest kernel OOM-kills inside its OWN memory budget
#   - the fork storm exhausts the guest's OWN process table
#   - the host-side timeout fires and the VM is destroyed
#   - nothing else on the host notices, because nothing else is in that VM
#
# The visitor sees: exit_code != 0, and a timeout message.
# You see: one more line in the runs table.

None of this depends on the guest cooperating. The memory ceiling is the VM's allocation, the process table is the guest's own, and the wall-clock cap is enforced by the host, which can destroy the machine without needing an interrupt to land inside a wedged process. That's a different guarantee than a cgroup on a shared box, and it's why the feature can stay open to anonymous traffic.

Fencing the network, including your own

Isolation contains the process; it doesn't contain the packets. A microVM with unrestricted egress is still a fine platform for scraping, spam, and DDoS participation — and it's still inside your network. Block the link-local metadata range and your internal CIDRs at the VM's network namespace rather than in application code, so 169.254.169.254 and your private subnets are unreachable no matter what the snippet asks for. Then decide whether the playground needs outbound internet at all: allowlist your own public API if the samples call it, otherwise default-deny — a miner that can't reach a pool is just a CPU burning against a ten-second timeout.

Also fence what goes in. Never inject an API key, a database URL, or a cloud credential into a playground guest — this is the one environment where you should assume every environment variable will be printed to a stranger's screen within a week. If a sample needs auth, mint a short-lived, heavily-scoped demo token per run.

What it actually costs to run

The worry is that a VM per Run click is unaffordable when the page goes viral. Two mechanisms say otherwise. Copy-on-write memory means every sandbox restores the same baked snapshot with pages mapped private, so the guest kernel and language runtime are shared across concurrent VMs until one writes — a hundred concurrent runs cost nowhere near a hundred VMs' worth of RAM. And a run is short by construction: a ten-second cap means you pay for concurrency, not for traffic. Networking isn't the constraint; a single PandaStack agent pre-allocates 16,384 /30 subnets, so the ceiling is host memory and CPU.

The cheapest run is still the one you don't do. Content-hash the snippet plus the template version and cache the result, and the common case — a reader clicking Run on the sample exactly as written — becomes a cache hit that never touches a VM. Only edited snippets, the interesting ones, spend real compute. With a global concurrency cap alongside it, the worst case on a Hacker News day is a queue and a 503, not an invoice.

The honest summary

A runnable docs playground is a compute product wearing a documentation costume, and should be designed like one. If your samples are client-side, put them in the browser and stop reading. If they need a real OS, a real network, or the actual runtime your platform ships, run them server-side and make each run a disposable microVM with a hard timeout, a byte cap, an egress policy, and a rate limiter in front. What used to make that impractical was latency, and snapshot-restore removed it: a hardware-isolated VM at p50 179ms is fast enough to sit behind a button a stranger clicks impatiently. You'll still get the fork bomb during launch week. It'll just be a row in a table instead of a page.

Frequently asked questions

How do I run anonymous visitors' code from my docs without an account system?

Treat every run as untrusted and disposable: create a fresh microVM per run, write the snippet into the guest filesystem, exec it with a hard wall-clock timeout, cap the returned output, then destroy the VM. Because there is no account to ban, your controls have to be per-request rather than per-tenant — rate limit by IP and session, cap snippet size, and cap total in-flight runs globally so a spike degrades into 429s instead of an outage. Set a TTL on every sandbox so a VM survives your own handler crashing. On PandaStack a create is p50 179ms via snapshot-restore, which is what makes one-VM-per-anonymous-click practical rather than absurd.

Should I use WASM, WebContainers, or a server-side sandbox for a docs playground?

Use the browser when your samples are genuinely client-side — a JavaScript library, a CSS tool, a pure-function API, a regex demo. WASM and WebContainers cost you nothing per run and have essentially no abuse surface, since the visitor spends their own CPU. Move server-side when your samples need native dependencies that nobody has ported to WASM, real sockets, subprocesses, a shell, or a language your readers' browsers don't ship. The other reason to go server-side is fidelity: a microVM runs the same environment your product does, so a passing sample is evidence and you can execute the same snippets in CI. Verify any specific runtime's current capabilities against its own docs.

How do I stop crypto miners and abuse in a public code playground?

Attack it from three sides at once. Cap wall-clock time per run so a miner gets seconds rather than hours, and enforce that cap host-side where it doesn't need the guest to cooperate. Default-deny outbound network egress from the guest, or allowlist only the endpoints your samples legitimately call — a miner that cannot reach a pool is just a CPU spinning against a timeout. Then rate limit anonymous traffic per IP and per session and cap global concurrency, so a script can't turn your docs into a compute grid. Also block the cloud metadata address 169.254.169.254 and your internal CIDRs at the network namespace, since a playground guest is a machine inside your VPC that strangers type commands into.

What happens if someone pastes a fork bomb into an embedded code playground?

In a shared worker process it is an incident — the box wedges, every queued request stalls behind it, and somebody gets paged. In a per-run microVM it is bounded and boring: the fork storm exhausts that guest's own process table, a memory bomb hits that guest's own RAM budget and gets OOM-killed inside the VM, and when the host-side wall-clock timeout fires you destroy the whole machine. No interrupt has to land inside a wedged process, because you kill the VM rather than the process. The visitor sees a non-zero exit code and a timeout message; you see one more row in the runs table.

Is a microVM per playground run too slow or too expensive at scale?

Not with snapshot-restore. A PandaStack sandbox is restored from a baked snapshot rather than booted — p50 179ms and p99 203ms end to end, with about a 49ms restore step — so the fresh-VM cost sits well inside the budget for a button a reader clicks impatiently. On cost, copy-on-write memory means concurrent VMs share the identical guest kernel and runtime pages until one writes, so a hundred concurrent runs are nowhere near a hundred VMs' worth of RAM, and a run capped at ten seconds means you pay for concurrency rather than for traffic. Cache results by content hash of the snippet plus template version and the common case — an unedited sample — never touches a VM at all.

Keep reading

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.