Skip to content

Edge Runtime Isolates

supabase/edge-runtime is a Rust process embedding two different kinds of Deno runtime — one unrestricted main worker that routes every incoming request, and one sandboxed user worker (isolate) per function, created with allow_all: false and an explicit, narrow permission list — and the “cold start” What Are Edge Functions already measured is the real, one-time cost of creating and permission-configuring exactly that isolate.

Proof: the real permission model, from the Rust source itself

Section titled “Proof: the real permission model, from the Rust source itself”

Not a docs paraphrase — crates/base/src/runtime/permissions.rs (permalink) in full:

pub fn get_default_permissions(kind: WorkerKind) -> PermissionsOptions {
match kind {
WorkerKind::MainWorker | WorkerKind::EventsWorker => PermissionsOptions {
allow_all: true,
allow_env: Some(Default::default()),
allow_net: Some(Default::default()),
allow_ffi: Some(Default::default()),
allow_read: Some(Default::default()),
allow_run: Some(Default::default()),
allow_sys: Some(Default::default()),
allow_write: Some(Default::default()),
allow_import: Some(Default::default()),
..Default::default()
},
WorkerKind::UserWorker => PermissionsOptions {
allow_all: false,
allow_env: Some(Default::default()),
allow_net: Some(Default::default()),
allow_read: Some(Default::default()),
allow_write: Some(Default::default()),
allow_import: Some(Default::default()),
allow_sys: Some(vec!["hostname".to_string()]),
..Default::default()
},
}
}

This is the exact split this course has referred to informally: WorkerKind::MainWorker gets allow_all: true — the thing that parses your request, decides which function it’s for, and proxies it has no sandbox at all, by design (it’s Supabase’s own trusted code, never yours). WorkerKind::UserWorker — your function — gets allow_all: false, and every one of allow_read/allow_write/allow_ffi/allow_run is either absent (denied outright, like allow_ffi/allow_run here) or present with an empty default allow-list, meaning “reads/writes/env/net are permitted only against a list this struct doesn’t populate here” — the actual allowed paths (your function’s own module directory, /tmp, the loopback network for reaching Postgres/the Data API) are granted narrowly elsewhere in the worker-creation path, not in this default.

Proof: the sandbox is real, not a suggestion

Section titled “Proof: the sandbox is real, not a suggestion”

A function in this lesson’s own namespace, trying to read a file that has nothing to do with its own module directory:

supabase/functions/int-edge-probe/index.ts
Deno.serve(async (req) => {
const url = new URL(req.url);
if (url.pathname.endsWith('/read-hosts')) {
try {
const text = await Deno.readTextFile('/etc/hosts');
return new Response(JSON.stringify({ ok: true, bytes: text.length }), { status: 200 });
} catch (err) {
return new Response(JSON.stringify({ ok: false, name: (err as Error).name, message: (err as Error).message }), { status: 500 });
}
}
return new Response(JSON.stringify({ ok: true, now: Date.now() }), { status: 200 });
});
$ curl -s "$API_URL/functions/v1/int-edge-probe/read-hosts" -H "apikey: $ANON_KEY"
{"ok":false,"name":"NotFound","message":"path not found: /etc/hosts: readfile '/etc/hosts': path not found: /etc/hosts: readfile '/etc/hosts': path not found: /etc/hosts"}
$ docker exec supabase_edge_runtime_probe cat /etc/hosts
127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00:: ip6-localnet

Two things worth being precise about, both proven directly above. First, docker exec into the container reads /etc/hosts fine — the file genuinely exists on that filesystem; the isolate’s own sandboxed view of the filesystem is what denies it, not a missing file. Second, the error name is NotFound, not PermissionDenied — the isolate reports a path outside its allowed set as if it simply doesn’t exist, rather than confirming “it exists, but you can’t read it.” This matches Deno’s own security posture of not leaking information about paths a sandboxed script has no business knowing about, and it is a real, provable difference from what most developers expect the error to look like (see Common Misconceptions).

flowchart TD
  http["HTTP request: /functions/v1/int-edge-probe"] --> kong["Kong gateway"]
  kong --> main["Main worker (allow_all: true, unrestricted)"]
  main -->|"parse path, resolve service_name"| pool{"Isolate already warm for this function?"}
  pool -->|yes| warm["Reuse existing UserWorker isolate"]
  pool -->|no| cold["EdgeRuntime.userWorkers.create({ servicePath, memoryLimitMb, workerTimeoutMs, ... })\nboot a NEW UserWorker isolate (allow_all: false)"]
  cold --> exec["worker.fetch(req) inside the sandboxed isolate"]
  warm --> exec
  exec --> resp["Response back through the main worker"]
One main worker, one isolate per function, two different sandboxes

Proof: cold vs warm, including a heavy npm: import

Section titled “Proof: cold vs warm, including a heavy npm: import”

What Are Edge Functions already measured a bare Deno.serve hello-world at ~66ms cold, ~6-7ms warm on this same Mac. Re-measuring int-edge-probe (also a bare Deno.serve, no imports) against an already-running Edge Runtime process shows a smaller absolute gap — a reminder that “cold” always means this isolate’s first request, not the whole runtime’s first request ever; the main worker process itself, and Deno’s own internal V8 snapshot, were already warm from serving other functions in this session:

$ curl -s -o /dev/null -w "first (cold): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe" -H "apikey: $ANON_KEY"
first (cold): time_total=0.008365s
$ curl -s -o /dev/null -w "second (warm): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe" -H "apikey: $ANON_KEY"
second (warm): time_total=0.004279s

Roughly 2x, not 10x — because this isolate’s own module (one file, zero imports) is nearly free to instantiate even cold. The interesting comparison is a function whose cold start actually has work to do — resolving and loading an npm: package:

supabase/functions/int-edge-probe-npm/index.ts
import { z } from 'npm:[email protected]';
const Schema = z.object({ n: z.number() });
Deno.serve(async (req) => {
const parsed = Schema.safeParse({ n: 1 });
return new Response(JSON.stringify({ ok: parsed.success }), { status: 200 });
});
$ curl -s -o /dev/null -w "first (cold): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe-npm" -H "apikey: $ANON_KEY"
first (cold): time_total=0.187833s
$ curl -s -o /dev/null -w "second (warm): time_total=%{time_total}s\n" "$API_URL/functions/v1/int-edge-probe-npm" -H "apikey: $ANON_KEY"
second (warm): time_total=0.003650s

~51x, not ~2x — a real, measured, isolate-specific cost: the first request to int-edge-probe-npm has to resolve npm:[email protected] against the npm registry, download it, and instantiate it inside the new isolate; every warm request after that skips all of it. This is the concrete reason the edge-functions-and-the-database lesson’s own module-scope-client pattern matters even more for functions with real dependencies — the amortization of a one-time cost over many warm requests is bigger the heavier the import.

Proof: the module cache these downloads land in

Section titled “Proof: the module cache these downloads land in”
$ docker exec supabase_edge_runtime_probe find /root/.cache/deno -maxdepth 1
/root/.cache/deno
/root/.cache/deno/node_analysis_cache_v2
/root/.cache/deno/dep_analysis_cache_v2
/root/.cache/deno/remote
/root/.cache/deno/npm
$ docker exec supabase_edge_runtime_probe find /root/.cache/deno/npm/registry.npmjs.org/zod -maxdepth 2
/root/.cache/deno/npm/registry.npmjs.org/zod
/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8
/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/README.md
/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/index.d.ts
/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/package.json
/root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/lib

[email protected] is sitting on disk, unpacked, inside the container’s own Deno cache — this is what makes the second isolate boot for the same function (or even a brand-new isolate after this one is retired) skip network resolution entirely; only the very first request to ever hit this exact package+version on this container pays the download cost. node_analysis_cache_v2/dep_analysis_cache_v2 (both SQLite-backed — the -wal/-shm files alongside them confirm it) are Deno’s own module-graph/Node-compat analysis caches, serving the same purpose one layer up: skip re-parsing a module’s dependency graph on every cold isolate.

Proof: the main worker’s own request-routing shape

Section titled “Proof: the main worker’s own request-routing shape”

Supabase doesn’t publish the exact production main-service script, but its own test fixture (crates/base/test_cases/main/index.ts, permalink) — used by the project’s own test suite to drive the same EdgeRuntime.userWorkers API the real main worker calls — shows the exact shape:

const path_parts = pathname.split("/");
let service_name = path_parts[1];
...
const servicePath = `./test_cases/${service_name}`;
...
return await EdgeRuntime.userWorkers.create({
servicePath,
memoryLimitMb, // proof above's isolate cost is bounded by this
workerTimeoutMs, // the wall-clock limit docs describe, as a real parameter
cpuTimeSoftLimitMs,
cpuTimeHardLimitMs,
noModuleCache,
noNpm,
envVars,
context,
staticPatterns: ["**/*.wasm"],
});

The first path segment (/int-edge-probe/read-hosts → int-edge-probe) is exactly the service_name PostgREST-style routing this lesson’s own curls relied on, and EdgeRuntime.userWorkers.create is the literal call that produces the “cold start” this lesson (and the earlier what-are-edge-functions lesson) measured — workerTimeoutMs/cpuTimeSoftLimitMs/cpuTimeHardLimitMs/memoryLimitMb are the mechanism-level names behind the platform limits Supabase’s own docs state as 256MB memory, 2s CPU time per request, 150s/400s wall-clock (free/paid), and the 546 WORKER_RESOURCE_LIMIT response (troubleshooting docs) is what an isolate returns once it exhausts one of them: “Once an isolate uses 50% of any resource, it will finish the current request and then shut down. However, if that remaining request exhausts all CPU or memory before completion, the isolate will terminate immediately and return a 546 response.”

Proof: the policy config knob is a real Rust enum, not a documentation-only concept

Section titled “Proof: the policy config knob is a real Rust enum, not a documentation-only concept”

This project’s own [edge_runtime] config (tools/probe/supabase/config.toml) sets policy = "per_worker" with the comment “enables hot reload during local development” vs. the "oneshot" fallback. That maps directly onto a real Rust type, SupervisorPolicy, in crates/base/src/worker/pool.rs (permalink):

pub enum SupervisorPolicy {
#[default]
PerWorker,
PerRequest {
oneshot: bool,
},
}

PerWorker is what let this lesson’s own warm-timing proofs above reuse the same isolate across multiple requests (the ~4ms/~3.6ms “warm” numbers); PerRequest { oneshot: true } — the CLI’s oneshot config value — would instead retire the isolate after every single request, trading the warm-reuse speedup for a fresh, fully isolated sandbox per call (useful for debugging a function that might be leaking state between requests, at a real per-request cold-start cost).

🐛 Spot the bug

// AI-generated "read the request's client IP from a trusted proxy config file"
Deno.serve(async (req) => {
const proxyConfig = await Deno.readTextFile('/etc/nginx/nginx.conf');
const trustedRanges = parseProxyConfig(proxyConfig);
const clientIp = resolveClientIp(req, trustedRanges);
return new Response(JSON.stringify({ clientIp }));
});
function parseProxyConfig(text: string) { /* ... */ return []; }
function resolveClientIp(req: Request, ranges: string[]) { /* ... */ return req.headers.get('x-forwarded-for'); }
Show the answer

This is exactly the sandbox proven above, hit from a different angle: a real Edge Function isolate has allow_read with no path outside its own module directory permitted by default (crates/base/src/runtime/permissions.rs’s WorkerKind::UserWorker entry, quoted in full above) — /etc/nginx/nginx.conf is not part of that isolate’s filesystem view at all, whether or not the file exists on the container, or even whether nginx is part of this stack’s topology in the first place (it isn’t — Kong is the gateway here). This call will throw the same NotFound this lesson proved above, not a helpful “config file not found, did you mean…”, and definitely not the trusted-range data the function’s logic assumes it got. The fix is architectural, not a permissions flag to flip: an Edge Function has no business reading gateway/proxy config off disk at all — x-forwarded-for (or better, a platform-provided header your gateway sets and your function trusts without re-deriving trust from a config file it can never see) is the only input actually available inside the sandbox, and if genuine IP-allowlisting logic is needed, it belongs in the gateway (Kong) or in a Postgres RPC function reading a table your Edge Function calls with service_role, not inside the isolate itself.

Think of the Edge Runtime as a single trusted receptionist (the main worker, allow_all: true, one process) standing in front of a bank of soundproofed interview rooms (user worker isolates, allow_all: false). The receptionist can go anywhere in the building and reads every visitor’s badge (the request path) to decide which room to send them to — reusing an already-occupied, already-warm room when policy allows it (per_worker), or building a brand-new room from scratch when it doesn’t (a cold start, proven above to cost real, measurable milliseconds — dramatically more when the room’s occupant needs furniture shipped in from outside, i.e. an npm: import). Once inside a room, a visitor (your function code) can only touch what that room was explicitly built with — asking for something outside it doesn’t get “sorry, that’s restricted,” it gets “that doesn’t exist here,” by design.

Design choiceBenefitCost
Two-runtime split: unrestricted main worker, sandboxed user workerYour code can never accidentally (or maliciously) touch the routing layer or other tenants’ isolates — proven above via a real, unbypassable filesystem denialAn extra process boundary and IPC hop (the main worker’s own worker.fetch(req) call) on every single request, even a warm one
allow_all: false with an empty default allow-list per permission kindA new capability (reading a new path, a new env var) requires an explicit, auditable grant somewhere in the worker-creation path, not “off by default, easy to forget to lock down”Debugging “why can’t my function read this file” requires understanding the sandbox model this lesson just proved, not just Deno’s general permission flags
per_worker isolate reuse (this stack’s default)The 2x-51x warm speedup proven above, on every request after the first to a given functionState genuinely persists across requests inside one isolate (module-scope variables, open connections) — a source of real bugs if a function assumes each request starts totally fresh
npm: packages resolved and cached to disk on first useEvery isolate after the first (even for a different, brand-new isolate of the same function) skips the network entirelyThe very first real request to a newly-deployed function with a new dependency pays a real, measured cost (188ms here) that has nothing to do with the function’s own logic
  • “Edge Function sandbox violations throw a PermissionDenied error.” Disproved above with a real, unedited error: the isolate reports NotFound, not PermissionDenied, for a path outside its allowed set — deliberately indistinguishable from “this file genuinely doesn’t exist.”
  • “Cold start cost is roughly the same for every function, regardless of what it imports.” Disproved above: a bare Deno.serve cold-started in ~8ms on an already-warm runtime process, while the same isolate class with one npm: import cold-started in ~188ms — a ~23x difference in absolute cold-start cost driven entirely by what has to be resolved and loaded, not by anything about isolates in general.
  • “The main worker runs in the same sandbox as user code, just with more permissions.” Disproved directly by the Rust source above: WorkerKind::MainWorker gets allow_all: true — categorically unrestricted, not “restricted, but with a longer allow-list” the way WorkerKind::UserWorker is.
  • “docker exec into the Edge Runtime container proves what a function can access.” Disproved above: docker exec cat /etc/hosts succeeds trivially (the container’s own root filesystem has no sandbox), while the exact same path from inside a UserWorker isolate fails — the container and the isolate are two different security boundaries, and only the isolate’s is the one your function’s code actually runs inside.
The real Rust source for get_default_permissions shows WorkerKind::MainWorker with allow_all: true and WorkerKind::UserWorker with allow_all: false. What does this split actually mean?
The real curl proof against Deno.readTextFile('/etc/hosts') inside a UserWorker isolate returned an error named "NotFound", not "PermissionDenied" — even though docker exec into the same container reads that file fine. What does this prove?
The measured cold-start numbers showed int-edge-probe (no imports) at ~8ms cold vs int-edge-probe-npm (one npm: import) at ~188ms cold — roughly a 23x gap between the two functions' own cold starts. What does this prove about "cold start cost"?
After the first request to int-edge-probe-npm, [email protected] was found unpacked on disk at /root/.cache/deno/npm/registry.npmjs.org/zod/3.23.8/. What does this cache location and its contents prove about subsequent cold starts?
config.toml's policy = "per_worker" maps to which real Rust type in the edge-runtime source, and what does the alternative value actually change?