Lightweight AI Sandboxes with WebAssembly: Pyodide and QuickJS
When you can't run a container — serverless functions, the edge, cold-start-sensitive paths — you can still isolate untrusted code in-process with WebAssembly. This runs LLM-written Python via Pyodide and JavaScript via QuickJS, each with hard memory and time limits.
Key takeaways
- A WebAssembly interpreter is an in-process sandbox: the guest runs in sealed linear memory and can't touch your host's memory, files, or network unless you hand it a binding. It starts in milliseconds, so it fits serverless and edge runtimes where you can't spawn a container.
- QuickJS via quickjs-emscripten runs untrusted JavaScript with a hard setMemoryLimit and a deadline-based setInterruptHandler. The catch is manual handle disposal — every value you pull out of the VM must be freed, and Scope.withScope is how you keep that honest.
- Pyodide can't be interrupted mid-run, so a while True in the model's Python will hang forever. The fix is to run it inside a worker_threads worker and kill it with worker.terminate() when it blows the deadline.
- This is a memory-isolation boundary, not a VM. It's right for math, parsing, and untrusted expressions from your own agents. Against deliberately hostile native exploit chains, pair it with a real microVM.
Summary
TL;DR: When you can't run a container — serverless functions, edge runtimes, latency-sensitive paths — you can still isolate the code your agent writes by running it inside a WebAssembly interpreter, in your own process. The guest lives in sealed WASM linear memory and can't reach your host unless you hand it a binding. This tutorial runs LLM-written JavaScript through quickjs-emscripten and Python through Pyodide, each with a hard memory cap and a wall-clock timeout, and wires the JS runner into a Vercel AI SDK agent as a tool.
When you can run containers, a locked-down Docker container is the right answer for executing code an agent wrote. But a container needs a container host. Try to spin one up from inside a Vercel function, a Cloudflare Worker, or an AWS Lambda and you'll find there's no Docker daemon to talk to. Even where you can reach a daemon, docker create plus start plus teardown adds a few hundred milliseconds per call — rough on a path a user is waiting on.
So here's the constraint I keep running into: an agent deployed to the edge, the model just wrote four lines of JavaScript to normalize a date range, and I need to run those four lines right now, in this invocation, without a second machine and without a cold start I can feel. No container is going to happen here.
The move is to stop reaching outward for isolation and pull it inward. A WebAssembly interpreter runs inside your Node or edge process, but the code it executes lives in a separate slab of memory — WASM linear memory — that the guest can't see past. It's an isolation boundary you carry with you, and it starts in single-digit milliseconds because there's no VM to boot and no image to pull.
Why we're doing this
The isolation comes from how WebAssembly memory works, and it's worth being precise about it because the whole safety argument rests on this one property.
A WASM module gets a single contiguous block of memory — its linear memory — and every load and store is bounds-checked against the size of that block. There is no pointer the guest can construct that reaches outside it. Run a JavaScript interpreter that was itself compiled to WASM, feed it a script, and that script's strings, objects, and stack all live inside the interpreter's linear memory. The script has no way to name an address in your process. It can't read your environment, open your filesystem, or reach the network, because none of those things exist inside the sandbox. The only capabilities the guest has are the functions you explicitly import into the module. Hand it nothing, and it can compute and nothing else.
That's a strong boundary for one class of work and a weak one for another, so let me draw the line.
It's good for: arithmetic the model shouldn't do in its head, parsing and reshaping data, running transforms over values, and evaluating untrusted expressions — the "compute this, don't estimate it" cases. It's the answer when the code is short-lived, pure, and you want it isolated somewhere a container can't go.
It stops at: there's no operating system in there. No processes, no real filesystem, no sockets — mostly a feature, but anything that genuinely needs those won't run. You enforce timeouts yourself, because a busy loop inside the guest is just your own process spinning. And it's not a substitute for a VM if your threat model includes someone firing hand-crafted native exploit chains at the WASM runtime itself. Memory isolation holds against a script misbehaving; a determined attacker probing the interpreter's C code for a bug is a different fight, and for that you want Cloudflare's managed sandboxes or a real microVM underneath.
Here's how the in-process WASM approach sits next to the two other ways people isolate code without a full container:
| Approach | Isolation model | Cold start | Language | Native or WASM |
|---|---|---|---|---|
| Pyodide | WASM linear memory; sealed, no OS | ~1–3 s first load, then reused | Python (+ pure-Python pip via micropip) | WASM |
QuickJS (quickjs-emscripten) | WASM linear memory; sealed, no OS | ~5–15 ms per context | JavaScript (ES2023) | WASM |
| isolated-vm | V8 isolate; separate heap, same process | ~2–5 ms per isolate | JavaScript | Native (Node-only) |
isolated-vm is in the table on purpose. It runs untrusted JS in a fresh V8 isolate — a separate heap inside your process, with its own memory limit — and it's faster than QuickJS-on-WASM because the code runs as real optimized V8 rather than an interpreter running on an interpreter. But it's a native Node addon: it needs a compile step, runs on Node only (not the edge, not Workers, not the browser), and its boundary is V8's isolate rather than WASM linear memory. On Node needing only JavaScript, isolated-vm is often the pragmatic pick. If you need Python, or need to run where you can't ship a native binary, WASM is what's left — and it's a good "what's left."
The architecture
Both runners follow the same shape: instantiate the WASM interpreter once and keep it warm, then for each request hand it the untrusted code inside a boundary that caps memory and enforces a deadline. The difference is how you enforce the deadline, and that difference is the whole reason Python needs a worker and JavaScript doesn't.
agent / edge function (one process)
│
├──────────────► runJs(code) [QuickJS]
│ │
│ ├─ newRuntime (reuse warm module)
│ ├─ setMemoryLimit(bytes) ── hard cap
│ ├─ setInterruptHandler(deadline) ── self-stops
│ ├─ context.evalCode(code)
│ └─ dispose every handle ◄── the sharp edge
│
└──────────────► runPython(code) [Pyodide]
│
worker_threads worker (killable)
├─ loadPyodide (once per worker)
├─ setStdout({ batched }) ── capture prints
├─ runPythonAsync(code)
└─ main thread: worker.terminate() on deadline
(Pyodide can't self-interrupt mid-run)
QuickJS can stop itself: it calls an interrupt handler between bytecode ops, so a deadline check there is enough to break an infinite loop from the inside. Pyodide can't — once runPythonAsync enters a tight Python loop, control never comes back to JavaScript to check anything. The only way to stop it is to kill the thread it's running on from the outside, which is why the Python runner lives in a worker.
Step 1: JavaScript with quickjs-emscripten
Start with the JS runner, because it shows the memory model and the manual-disposal discipline in the clearest form.
mkdir wasm-sandbox && cd wasm-sandbox
npm init -y
npm install quickjs-emscripten ai @ai-sdk/openai zod dotenv
npm install -D typescript @types/node tsx
quickjs-emscripten ships the QuickJS interpreter compiled to WASM plus a TypeScript wrapper. getQuickJS() loads and instantiates the WASM module and resolves to a factory you reuse for the life of the process — you pay the instantiation cost once.
The thing to internalize first: values that cross between your JS and the guest VM are handles, and handles are manually managed memory. context.evalCode returns a handle to a result living inside the VM's linear memory. Don't dispose it and it leaks — inside the WASM heap you capped, so leaks eventually turn into out-of-memory errors on a long-running process. This is the sharp edge, and the library gives you Scope to keep it from cutting you.
// runJs.ts
import {
getQuickJS,
Scope,
shouldInterruptAfterDeadline,
type QuickJSWASMModule,
} from "quickjs-emscripten";
let modulePromise: Promise<QuickJSWASMModule> | null = null;
// Instantiate the WASM module once and reuse it for every call. This is the
// difference between a ~10ms run and re-paying WASM instantiation each time.
function getModule(): Promise<QuickJSWASMModule> {
modulePromise ??= getQuickJS();
return modulePromise;
}
export interface JsResult {
ok: boolean;
value?: unknown; // JSON-serializable result, on success
error?: string; // the guest's error message, on failure
timedOut: boolean;
}
export async function runJs(
code: string,
{ memoryMB = 32, timeoutMs = 1000 }: { memoryMB?: number; timeoutMs?: number } = {},
): Promise<JsResult> {
const QuickJS = await getModule();
// A runtime owns the memory and the interrupt handler; a context is a
// global object inside it. One runtime, one context, per call.
const runtime = QuickJS.newRuntime();
runtime.setMemoryLimit(memoryMB * 1024 * 1024); // hard ceiling on guest heap
runtime.setMaxStackSize(1024 * 1024); // 1 MB call stack — stops runaway recursion
// The interrupt handler is called between operations. Returning true aborts
// the running script. shouldInterruptAfterDeadline returns a handler that
// trips once we're past the wall-clock deadline — this is our timeout.
runtime.setInterruptHandler(
shouldInterruptAfterDeadline(Date.now() + timeoutMs),
);
try {
return Scope.withScope((scope) => {
// Everything scope.manage() wraps is disposed when this block exits,
// on success OR throw. This is how you stay honest about handles.
const context = scope.manage(runtime.newContext());
const result = context.evalCode(code);
if (result.error) {
// result.error is a handle too — manage it so it's freed.
const err = context.dump(scope.manage(result.error));
return {
ok: false,
error: typeof err === "object" ? JSON.stringify(err) : String(err),
timedOut: false,
};
}
// dump() copies the guest value out into a plain JS value. We manage the
// handle so it's freed; the copy we return lives in our heap, not the VM's.
const value = context.dump(scope.manage(result.value));
return { ok: true, value, timedOut: false };
});
} catch (e) {
// An interrupt (timeout) surfaces here as a thrown error from evalCode.
return {
ok: false,
error: e instanceof Error ? e.message : String(e),
timedOut: true,
};
} finally {
// The runtime is not managed by the scope; dispose it explicitly. Disposing
// it also tears down the linear memory the guest was using.
runtime.dispose();
}
}
Three things are load-bearing here and easy to get wrong:
setMemoryLimitis the safety property, not the timeout. A script that doesconst a = []; while (true) a.push(a)will hit the memory ceiling and get killed with an out-of-memory error inside the VM, long before it can exhaust your host. Set it low — 32 MB is plenty for expression evaluation and data transforms. The cap is what makes a hostile allocation loop harmless.- The interrupt handler is your only defense against a CPU loop.
while (true) {}allocates nothing, so the memory limit never trips.shouldInterruptAfterDeadlineis what stops it: QuickJS calls the handler between bytecode operations, the handler sees the clock has passed the deadline, returnstrue, and the eval aborts. Without a handler, that loop spins forever inside your process. Scope.withScopeis not optional hygiene, it's the correctness mechanism. Every handle youscope.manage()— the context, the result value, the error — gets disposed when the block exits, whether it returned or threw. Miss one and you leak WASM memory. On a per-request path that leak compounds until the module OOMs and every subsequent call fails. Manage everything.
Give it a smoke test:
// smokeJs.ts
import { runJs } from "./runJs";
async function main() {
// Real arithmetic the model shouldn't guess at.
console.log(await runJs("const n = 4_812_033 / 1_750; [Math.floor(n/60), Math.round(n%60)]"));
// -> { ok: true, value: [ 45, 50 ], timedOut: false }
// CPU bomb: stopped by the interrupt handler, not the memory cap.
console.log(await runJs("while (true) {}", { timeoutMs: 500 }));
// -> { ok: false, error: "...interrupted", timedOut: true }
// Memory bomb: stopped by setMemoryLimit.
console.log(await runJs("const a = []; while (true) a.push(a);", { memoryMB: 16 }));
// -> { ok: false, error: "out of memory", timedOut: false }
}
main();
Neither failure case touched your host. The infinite loop cost 500ms of one core and vanished; the allocation loop hit a 16 MB wall inside the guest and died there. No filesystem, no network, no environment — nothing in the sandbox for them to reach.
Step 2: Wire QuickJS into a Vercel AI SDK tool
The runner is a plain async function, so exposing it to an agent is just wrapping it in a tool. Because it's in-process and starts in milliseconds, this is the runner you want on an edge or serverless deployment.
// jsTool.ts
import { tool } from "ai";
import { z } from "zod";
import { runJs } from "./runJs";
export const jsSandboxTool = tool({
description:
"Evaluate a JavaScript expression or short script in a sealed WASM sandbox " +
"with no network and no filesystem. Use this for arithmetic, date math, " +
"parsing, and data transforms instead of computing in your head. The value " +
"of the last expression is returned — make the last line the result you want.",
inputSchema: z.object({
code: z
.string()
.describe("A JavaScript expression or script. The last expression is the return value."),
}),
execute: async ({ code }) => {
const result = await runJs(code, { memoryMB: 32, timeoutMs: 1000 });
if (result.timedOut) {
return "The script was killed after exceeding its 1s time limit. Simplify it or remove any loops.";
}
if (!result.ok) {
// Hand the model the real error so it can read it and fix its own code.
return `Error: ${result.error}`;
}
return `Result: ${JSON.stringify(result.value)}`;
},
});
And the agent loop looks exactly like the container version — the tool is the only thing that changed:
// agent.ts
import "dotenv/config";
import { generateText, stepCountIs } from "ai";
import { openai } from "@ai-sdk/openai";
import { jsSandboxTool } from "./jsTool";
async function main() {
const { text } = await generateText({
model: openai("gpt-4o"),
system:
"When a question needs real computation, write a small JavaScript " +
"expression and run it with the js tool rather than working it out in " +
"your head. Read the tool result before answering.",
prompt:
"A job processes 4,812,033 records at 1,750 records per second. " +
"How long is that in minutes and seconds? Compute it exactly.",
tools: { js: jsSandboxTool },
stopWhen: stepCountIs(5),
});
console.log(text);
}
main();
Run it with npx tsx agent.ts. The model writes an expression, runJs evaluates it in the WASM sandbox in about ten milliseconds, and the exact answer comes back — no container, no daemon, nothing that wouldn't run unchanged inside a Vercel edge function.
Step 3: Python with Pyodide, in a worker
Python is the case people actually want, and it's harder, because Pyodide — CPython compiled to WASM — can't be interrupted once it's running. There's no equivalent of QuickJS's interrupt handler that reliably breaks a tight Python loop. If the model writes while True: pass, runPythonAsync never returns and never yields control back to cancel it.
The only dependable stop is to run Pyodide on a separate thread and kill that thread. In Node that's worker_threads, and worker.terminate() is the SIGKILL equivalent — it tears the worker down mid-run, WASM heap and all.
npm install pyodide
The worker loads Pyodide once, then waits for code to run. Capturing output is straightforward: pyodide.setStdout({ batched }) gives you each line the guest prints. runPythonAsync returns the value of the last expression, same as an interpreter session.
// pyWorker.ts — runs on a worker thread
import { parentPort } from "node:worker_threads";
import { loadPyodide, type PyodideInterface } from "pyodide";
let pyodidePromise: Promise<PyodideInterface> | null = null;
async function getPyodide(onLine: (s: string) => void): Promise<PyodideInterface> {
pyodidePromise ??= loadPyodide();
const pyodide = await pyodidePromise;
// batched() fires per line written to stdout (or on flush). Route prints
// back to the parent instead of the worker's own process.stdout.
pyodide.setStdout({ batched: onLine });
pyodide.setStderr({ batched: onLine });
return pyodide;
}
parentPort!.on("message", async (code: string) => {
const out: string[] = [];
try {
const pyodide = await getPyodide((line) => out.push(line));
const value = await pyodide.runPythonAsync(code);
parentPort!.postMessage({
ok: true,
stdout: out.join("\n"),
// Convert a returned Python object to a JS value if there is one.
value: value?.toJs ? value.toJs() : (value ?? null),
});
} catch (e) {
parentPort!.postMessage({
ok: false,
stdout: out.join("\n"),
error: e instanceof Error ? e.message : String(e),
});
}
});
The parent owns the timeout. It posts code to the worker, races the reply against a deadline, and if the deadline wins, it terminates the worker and spins up a fresh one for the next call.
// runPython.ts — runs on the main thread
import { Worker } from "node:worker_threads";
import { fileURLToPath } from "node:url";
export interface PyResult {
ok: boolean;
stdout: string;
value?: unknown;
error?: string;
timedOut: boolean;
}
let worker: Worker | null = null;
function spawnWorker(): Worker {
// tsx/ESM: point the Worker at this file's sibling. In a build, point at the
// compiled pyWorker.js instead.
const url = new URL("./pyWorker.ts", import.meta.url);
return new Worker(fileURLToPath(url));
}
function getWorker(): Worker {
worker ??= spawnWorker();
return worker;
}
export async function runPython(
code: string,
{ timeoutMs = 5000 }: { timeoutMs?: number } = {},
): Promise<PyResult> {
const w = getWorker();
return new Promise<PyResult>((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
// The only way to stop a hung Python run: kill the thread. The worker is
// now dead, so drop it — the next call will spawn a fresh one.
w.terminate();
worker = null;
resolve({ ok: false, stdout: "", timedOut: true });
}, timeoutMs);
w.once("message", (msg: Omit<PyResult, "timedOut">) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ...msg, timedOut: false });
});
w.postMessage(code);
});
}
A quick check:
// smokePy.ts
import { runPython } from "./runPython";
async function main() {
const stats = await runPython(
"import statistics as s\n" +
"data = [12, 7, 42, 19, 8, 31]\n" +
"print(f'mean={s.mean(data)} stdev={s.stdev(data):.2f}')",
);
console.log(stats.stdout); // mean=19.833333333333332 stdev=13.79
const hung = await runPython("while True: pass", { timeoutMs: 1500 });
console.log("timed out:", hung.timedOut); // true — worker was terminated
}
main();
The while True case is exactly the one QuickJS handles with an interrupt handler and Pyodide can't. The main thread waits 1.5 seconds, sees no reply, calls w.terminate(), and the hung interpreter — thread, WASM heap, and all — is gone. Terminating the worker destroys the loaded Pyodide instance, so the next call pays the load cost again; a pool (below) avoids that.
On packages: the standard library is baked in, and for pure-Python packages you can micropip. Inside the worker, before running user code:
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install("requests"); // pure-Python wheels only
But temper expectations at the edge. micropip fetches wheels over the network, and a sealed edge sandbox usually has no arbitrary network — so preload what you need at build time, and don't count on installing packages on demand in production. Compiled-extension packages (numpy, pandas) load through loadPackage from Pyodide's own CDN, not micropip, and that too needs network access you may not have.
Production considerations
The runners above are correct but naive about reuse. A few changes matter once real traffic hits them.
-
Instantiate the WASM module once, reuse it forever. Both
getQuickJS()andloadPyodide()are the expensive part — the WASM compile and instantiate. The code above memoizes them (modulePromise,pyodidePromise); never call them per request. For QuickJS, create a fresh runtime per call (~1ms) off the shared module; for Pyodide, keep the loaded instance in the worker and only re-load after aterminate(). -
Pool Pyodide workers. A single worker serializes calls, and every timeout throws the loaded interpreter away. Keep a small pool of pre-loaded workers, hand each execution to an idle one, and replace any that gets terminated in the background so the pool stays warm. Two to four covers a lot of concurrency without the per-call load tax.
-
Memory caps are the safety property. For QuickJS,
setMemoryLimitis what makes an allocation bomb a non-event; keep it as low as your workloads tolerate (16–64 MB). Pyodide's heap is larger and less granular to cap, which is part of why isolating it on its own thread matters — a bad Python run bloats a thread you can kill, not your request handler. -
Know the real cold-start numbers. A QuickJS context off a warm module evaluates in single-digit milliseconds; the one-time
getQuickJS()is tens of milliseconds. Pyodide is a different order: firstloadPyodide()is roughly 1–3 seconds because it's instantiating CPython, andloadPackage("numpy")adds more. Warm it at startup, never on the request path, and expect to re-pay it after a scale-to-zero. -
Be honest about the security boundary. This is memory isolation, and it's good against the failure modes agents actually produce — runaway loops, allocation blowups, and code that tries to touch a filesystem or network that isn't there. For your own agents on non-hostile input, it's a right-sized boundary that goes where a container can't. It is not a defense against someone fuzzing the QuickJS or Pyodide C code for a memory-safety bug to escape the runtime. Taking deliberately hostile native payloads from strangers? Run this inside a real microVM — defense in depth, not either/or.
Frequently Asked Questions (FAQ)
Is a WASM sandbox actually secure, or is it security theater? It's a real boundary for a specific threat: the guest runs in bounds-checked linear memory and literally cannot address your host's memory, and it has zero capabilities beyond the imports you give it — so no filesystem, no network, no environment, unless you deliberately wire them in. That defeats exfiltration, resource exhaustion (with the caps set), and destructive side effects. What it doesn't defeat is a memory-safety exploit against the interpreter's own C code. For code from your own agents and untrusted expressions, it's appropriate. For deliberately hostile native attackers, layer it under a VM.
Why does Python need a worker thread but JavaScript doesn't?
QuickJS calls an interrupt handler between bytecode operations, so a deadline check there can abort an infinite loop from inside the same thread. Pyodide has no equivalent that reliably breaks a running Python loop — once runPythonAsync is spinning, control never returns to JavaScript to cancel it. The only dependable stop is to kill the thread it runs on, which means worker_threads and worker.terminate().
What happens if I forget to dispose a QuickJS handle?
You leak memory inside the WASM heap you capped with setMemoryLimit. On a one-off script nothing visible happens; on a per-request server the leak compounds until the module hits its limit and every call after that fails with out-of-memory. Scope.withScope exists precisely so you don't have to track disposal by hand — it frees everything you scope.manage() on the way out, including on a thrown error.
When should I use isolated-vm instead of QuickJS?
When you're on Node, only need JavaScript, and want more speed. isolated-vm runs untrusted JS in a real V8 isolate — a separate heap with its own memory limit — so the code executes as optimized native V8 rather than an interpreter running on WASM, which is faster. The trade-offs: it's a native addon, so it needs a compile step and runs on Node only (not the edge, not Workers, not the browser), and its boundary is V8's isolate rather than WASM linear memory. No Python, either. Reach for WASM when you need Python or need to run where a native binary can't go.
Can Pyodide run pandas and numpy at the edge?
Sometimes, with caveats. numpy and pandas have Pyodide builds you load via pyodide.loadPackage(...), not micropip — and loadPackage fetches them over the network from Pyodide's CDN. If your edge sandbox has no arbitrary network (common), you can't pull them on demand; you'd need to bundle them at build time. They also add seconds to load and a lot of heap. For heavy data work with real libraries, a cloud sandbox like E2B is usually the better fit than in-process WASM.
Where this goes next
You've now got two in-process sandboxes that run the code an agent writes without any container at all — QuickJS for JavaScript with a self-enforced timeout, Pyodide for Python with a killable worker — both starting fast enough to sit on an edge request path. The boundary is sealed WASM memory, which is exactly right when the code is short-lived and pure.
But sealed and short-lived is also the limitation. There's no real filesystem, nothing survives between runs, and you can't pip install on demand. When your agent needs to write files, keep state across turns, install arbitrary packages, and generally work in something that behaves like a real machine, an in-process WASM boundary is the wrong tool. Part 5, a persistent coding agent sandbox with E2B, takes the opposite approach: a real, persistent filesystem the agent can live in across a whole session, instead of the sealed in-process one we built here.
Related reading
If your app already runs on Cloudflare, you can execute untrusted agent code right next to it — no separate container host or VPC. This builds an agent tool on the Cloudflare Sandbox SDK, from the Dockerfile and wrangler bindings to streamed output.
A locked-down container stops the obvious attacks. Here are the controls that stop a determined one: a non-root user, a tight seccomp profile, resource ulimits, a network egress allowlist, and a real kernel boundary with gVisor.
One-shot code execution can't build software. An autonomous coding agent needs a filesystem that survives across turns — to install dependencies, edit files, run the test suite, read the failures, and open a pull request. This builds that with a persistent E2B sandbox.
Let's build something great.
Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.