Skip to content

Worker Pool

The central component of the runtime. It manages the lifecycle of the Bun workers that run user apps in isolation, providing reuse via an LRU cache, health checks, metrics, and graceful shutdown. Without it, every request would spin up a worker from scratch.

For the routing pipeline that precedes the pool, see Runtime. For plugins that hook into the pool via onWorkerSpawn/onWorkerTerminate, see Plugin System.

src/libs/pool/
├── pool.ts # WorkerPool — LRU management, metrics
├── instance.ts # WorkerInstance — IPC + individual lifecycle
├── wrapper.ts # Code that runs inside the worker
├── config.ts # Loading + validation of manifest.yaml
├── metrics.ts # PoolMetrics
├── stats.ts # Calculation helpers (avgResponseTime, etc.)
└── types.ts # WorkerMessage, WorkerResponse, WorkerConfig
ComponentResponsibility
WorkerPoolLRU cache (quick-lru), on-demand creation, eviction, health timers
WorkerInstanceSpawn new Worker(wrapper.ts), IPC postMessage, timeout, status
wrapper.tsRuns in the worker thread: import(ENTRYPOINT), processes messages, injects <base href>
Request → pool.fetch(appDir, config, req) → getOrCreate(key)
├─ Cache hit → instance.fetch(req)
└─ Cache miss → new WorkerInstance → await READY → cache.set(key, …)

The public entry point is pool.fetch(). getOrCreate() is private and manages the cache — do not bypass it.

Creating → Ready → Active ⇄ Idle → Terminated
StateCondition
Creatingnew Worker() fired, waiting for READY
ReadyWorker loaded module, validated exports, sent READY
ActiveLast request less than idleTimeoutMs ago
IdleLast request more than idleTimeoutMs ago (worker stays alive)
Ephemeralttl=0 mode — created and destroyed per request
OfflineTerminated or critically failed

Structured messages via postMessage with a transferList for zero-copy:

// Main → Worker
type WorkerMessage =
| { type: "REQUEST"; reqId: string; req: SerializedRequest }
| { type: "IDLE" }
| { type: "TERMINATE" };
// Worker → Main
type WorkerResponse =
| { type: "READY" }
| { type: "RESPONSE"; reqId: string; res: SerializedResponse }
| { type: "ERROR"; reqId: string; error: string; stack?: string };

Request/Response bodies travel as a transferable ArrayBuffer, avoiding copies.

Workers are addressed by name in the URL. A namespaced (npm-scoped) worker @namespace/app — stored at <workerDir>/@namespace/app/<version>/ — is served at /@namespace/app/... (keep the @). An unscoped worker app serves at /app/.... Namespaces give teams/environments a separate context: @example/checkout, @staging/api, @production/api.

This is a logical grouping orthogonal to the physical multi-directory support (RUNTIME_WORKER_DIRS): a namespace can live in any worker dir, and the resolver scans them all. Plugins differ — they declare an explicit single-segment base in their manifest, so their @scope only affects storage/listing, not the served URL.

manifest.enabled (default true) gates whether a worker version is served. When false, the version is treated as not-installed and the base path 404s — no process restart needed. Toggle it via POST /api/workers/:scope/:name/:version/{enable,disable}; the endpoint edits the version’s manifest and clears the worker-config cache so the next request reflects it.

The TTL policy defines the entire personality of a worker:

PolicyBehavior
ttl = 0Ephemeral: worker discarded after each request. Boot per call. Higher latency. Use for stateless lambda-style handlers.
ttl > 0Persistent: worker reused. TTL is sliding — it resets on each request via touch(). Use for apps with state, DB connections, SSE, WebSocket.

idleTimeout does not terminate the worker. It only fires the onIdle event in the app, giving it a chance to do partial cleanup (close DB connections, flush caches). The worker remains in the cache until the TTL actually expires.

export default {
fetch(req) { /* ... */ },
onIdle() {
// Opportunistic cleanup — worker stays alive
db.releaseConnection();
},
onTerminate() {
// Before actual termination
db.close();
},
};
  • ttl >= timeout
  • idleTimeout >= timeout
  • If idleTimeout > ttl, the runtime adjusts it to ttl with a warning.

A hard limit on requests per worker, independent of TTL. Useful for mitigating memory leaks that accumulate over hours. Default: 1000.

manifest.yaml in the app directory defines the worker configuration:

entrypoint: index.ts # Default: auto-discovery
timeout: 30 # or "30s", "5m", "1h"
ttl: 0 # 0 = ephemeral
idleTimeout: 60 # notification only
maxRequests: 1000 # safety net
maxBodySize: "10mb" # or a number in bytes
lowMemory: false # Bun --smol
autoInstall: false # bun install --frozen-lockfile --ignore-scripts
visibility: public # public | protected | internal
publicRoutes: # auth bypass
- /health
- /api/public/**
env: # custom vars (filtered for sensitive values)
API_URL: https://api.example.com

Supported duration formats for timeout, ttl, idleTimeout: ms, s, m, h, d, w, y.

Workers do not inherit the runtime env. They receive only:

VariableSource
APP_DIRruntime — absolute path to the app
ENTRYPOINTruntime — entrypoint path
WORKER_IDruntime — unique UUID
WORKER_CONFIGruntime — JSON of WorkerConfig
NODE_ENVinherited
RUNTIME_*inherited (RUNTIME_WORKER_DIRS, RUNTIME_PLUGIN_DIRS, RUNTIME_LOG_LEVEL)
RUNTIME_API_URLruntime — internal URL (e.g. http://127.0.0.1:8000)
* (from manifest.env)manifest — after filtering sensitive patterns
* (from .env).env file in appDir — overrides manifest.env

Variables matching any pattern below are stripped before reaching the worker, with a warning in the log:

PatternExample
^(DATABASE|DB)_DATABASE_URL, DB_HOST
^(API|AUTH|SECRET|PRIVATE)_?KEYAPI_KEY, AUTH_KEY
_TOKEN$ACCESS_TOKEN
_SECRET$JWT_SECRET
_PASSWORD$DB_PASSWORD
^AWS_ / ^GITHUB_ / ^OPENAI_ / ^ANTHROPIC_ / ^STRIPE_Provider credentials

Each worker runs in a separate thread with:

  • Independent heap — separate GC, no leaks between apps.
  • Own module cache — different versions of the same package coexist.
  • Scoped envBun.env injected at spawn time, no global pollution.
  • smol mode optional via lowMemory: true (smaller heap, more aggressive GC).
  • Path traversal blocked — entrypoint validated to stay within APP_DIR.

The pool indexes workers by key name@version. The same app appearing in two different workerDirs, or two apps with the same key, results in an error:

Worker collision: "my-app@1.0.0" already registered from "/apps/my-app/v1",
cannot register from "/other/my-app/v1"

A periodic timer per worker. On each check, instance.isHealthy() validates:

CriterionCondition
Sliding TTL(now - ttlStartAt) < ttlMs
RequestsrequestCount < maxRequests
Critical errorshasCriticalError === false

Failure on any criterion → pool.retire(key) (removes from cache + terminates).

Timer interval: Math.min(idleTimeoutMs, ttlMs) / 2.

These mark a worker as permanently unhealthy:

  • Initialization timeout (READY not received within 30s).
  • Import error (syntax error, module not found).
  • Unhandled error during a request.

For ttl=0 apps, the pool enforces two global limits:

VariableDefaultPurpose
RUNTIME_EPHEMERAL_CONCURRENCY2Simultaneous requests in flight
RUNTIME_EPHEMERAL_QUEUE_LIMIT100Queue depth before returning 503

Queue overflow returns 503 Service Unavailable. Tune according to the app’s boot cost — apps with expensive startup should not use ttl=0 under heavy load.

pool.getMetrics() exposes pool-wide counters: activeWorkers, avgResponseTimeMs, hitRate/hits/misses, evictions, ephemeralConcurrency/ephemeralQueueDepth/ephemeralQueueLimit, memoryUsageMB, requestsPerSecond, and the total* lifetime counters.

worker.getStats() exposes per-instance: ageMs, idleMs, requestCount, errorCount, avgResponseTimeMs, status, totalResponseTimeMs.

wrapper.ts accepts three forms of default export:

// 1. Fetch handler
export default {
fetch(req: Request) { return new Response("ok"); },
};
// 2. Routes object (converted to Hono internally)
export default {
routes: {
"/": new Response("Home"),
"/api/posts/:id": {
GET: (req) => new Response(`Post ${req.params.id}`),
DELETE: () => new Response(null, { status: 204 }),
},
"/file": Bun.file("./public/index.html"),
},
};
// 3. SPA — set entrypoint: index.html; the wrapper serves it statically
// with <base href> injection. index.ts is NOT executed in this mode.
DoAvoid
ttl > 0 for apps with state or expensive connectionsttl = 0 for apps with heavy warmup
idleTimeout for partial cleanup via onIdleRelying on idleTimeout to terminate the worker
maxRequests as a safety netGlobal state in the worker (lost on recycle)
Appropriate timeout for slow operationsautoInstall in production (pre-install instead)
Tune RUNTIME_EPHEMERAL_* under loadUnlimited ttl = 0 under burst traffic

For shared state, externalize it (e.g. @buntime/plugin-keyval instead of a global Map in the worker).