Stateless workers
Everything else here assumes an actor is somebody — one identity, one activation, turns in order. Pure compute has none of that, so serializing it behind one turn sequence is a bottleneck the semantics never asked for.
import { defineWorker } from '@sigx/actors';
export const Resize = defineWorker({
type: 'Resize',
authorize: requireAuthenticated,
maxLocal: 8, // pool cap; default: hardwareConcurrency clamped to [4, 16]
methods: () => ({
async run(image: Uint8Array, width: number) {
return transform(image, width);
},
}),
});
await actor(Resize, 'any').run(img, 800); // callers look exactly the same
The contract
Two calls to the same key may run concurrently, on different pool members. The host keeps
up to maxLocal activations per (type, key), spun up under load, and each dispatch rides the
member with the fewest queued turns. ctx.key is still the key the caller addressed — it just
no longer names a single runner.
The default cap is navigator.hardwareConcurrency clamped to [4, 16], and 4 where that
global does not exist. The floor matters in containers: under a cgroup CPU quota
hardwareConcurrency reports the quota, not the machine, so a pod with
resources.limits.cpu: 1 reports 1. Pool members are activations that overlap at await
points, not threads, so a single core still benefits from a pool. An explicit maxLocal —
including 1 — is always honoured.
Always local, zero directory traffic. A worker activates on whichever host — or Cloudflare
isolate — received the call. No directory claim, no lookup, no routing, no 421 redirect, and
nothing for a cluster to fence, migrate or rebalance. Both invariants are gated in CI:
directory_ops == 0, pool ≤ cap.
For the same reason a worker call carries no routing token: calls for one key spread across the fleet instead of pinning to one pod. Check that your load balancer spreads an untokened request rather than hashing the empty key.
A pool is not a thread pool. Every member runs on the host's one event loop, so a
CPU-bound method blocks it exactly as one activation would — long enough, and it starves the
membership heartbeat. Heavy
synchronous compute belongs on worker_threads.
That locality is also what makes a worker the one thing a cluster can target: because it
runs wherever the call lands, choosing the host is meaningful.
members() and dispatchOn() / workerOn()
invoke a worker on a chosen member — every web-tier pod holding connections, say — in one
attempt with no re-routing.
No identity, so no identity-bound surface. state, persistence, reminders, tasks:,
subscriptions:, placement and reentrant do not exist on WorkerOptions — passing one is
a compile error. ctx.state, ctx.save() and friends are typed away by WorkerContext and
throw if reached through a cast.
What remains
- Authorization —
authorize/methodAuthorize/allowAnonymous, the same build gate. reads:— a pure read is the ideal cacheableGET.streams:— pure generators; an open stream pins its member against the sweep.onActivate/onDeactivatefor per-member warm-up and teardown. Load a model once per member, close it on the way out.ctx.timer,ctx.actor,ctx.publish.
Pool members idle-collect individually after idleAfterMs, so a quiet worker shrinks back to
zero footprint.
Two sharp edges
A same-key self-call is a deadlock, deterministically. reentrant does not exist for
workers, so ctx.actor(Self, ctx.key) throws
ActorDeadlockError rather than working only when the pool happens to
have a free member. A different key is a different pool and is fine.
Watches are refused. A watch is a state-change feed and a worker has no state.
Where they live
Workers live in *.actor.ts files like every other definition — not *.worker.ts, which
belongs to Vite's Web Worker convention. The build swaps them for the same wire client, so a
browser can call one directly.
In an app, app.defineWorker is the plugin-typed twin of app.defineActor.
When to reach for one
Validation, image or document transformation, parsing, embedding generation, fan-out helpers — anything where the answer depends only on the arguments.
If two calls about the same key would ever want to see each other's effects, you want an actor, not a worker.
Next steps
- The actor model — the identity a worker gives up.
- Cacheable reads — a natural fit for pure compute.
- Clustering — why workers need none of it.
