Sharding overview
Cluster sharding is the framework’s answer to “I have a few million entities, each needing its own actor, scattered across N nodes.” Examples: per-user sessions, per-IoT-device controllers, per-order coordinators, per-game-room actors.
The user gives the framework a way to extract an entity ID from each message; the framework hashes that ID to a shard; the coordinator decides which node hosts that shard; the node’s local region routes to that shard, which spawns the entity actor on demand. When nodes come and go, the coordinator rebalances shards across the new topology.
Each entity is one actor, on one node, at a time — just like a singleton, but scaled to N entities at once. Failover happens automatically: when a node leaves, its shards move elsewhere and the entities re-spawn there.
A minimal example
Section titled “A minimal example”import { match } from 'ts-pattern';import { Actor, Cluster, ClusterBootstrapOptions, StartShardingOptions } from 'actor-ts';
type AddCommand = { entityId: string; kind: 'add'; sku: string };type ViewCommand = { entityId: string; kind: 'view'; replyTo: ActorRef<Cart> };type CartCommand = AddCommand | ViewCommand;
class CartActor extends Actor<CartCommand> { private items: string[] = [];
override onReceive(command: CartCommand): void { match(command) .with({ kind: 'add' }, (c) => this.onAdd(c)) .with({ kind: 'view' }, (c) => this.onView(c)) .exhaustive(); }
private onAdd(command: AddCommand): void { this.items.push(command.sku); } private onView(command: ViewCommand): void { command.replyTo.tell({ items: this.items }); }}
// Setup — one-line cluster + sharding entry point:const { system, cluster } = await Cluster.bootstrap(ClusterBootstrapOptions.create('my-app'));
const startShardingOptions = StartShardingOptions.create<CartCommand>().withExtractEntityId((message) => message.entityId);const cartRegion = cluster.sharding.start('cart', CartActor, startShardingOptions);
// Usage — `tell` to the region, with entity ID inside the message:cartRegion.tell({ entityId: 'user-42', kind: 'add', sku: 'book-1' });cartRegion.tell({ entityId: 'user-42', kind: 'view', replyTo: ... });// ^^^^^^^^^^// Same ID → same entity actor, every time, regardless of node.cluster.sharding.start() accepts several calling shapes — pick
whichever fits the entity. The shortest is to let the entity declare
its own identity, which means neither the type name nor the entity-id
extractor is repeated at the call site:
class CartActor extends Actor<CartCommand> { static readonly shard = ShardKey.of<CartCommand>('cart', (command) => command.entityId); // ...}
const cartRegion = cluster.sharding.start(CartActor);const cart = cluster.sharding.entityRefFor(CartActor, 'user-42');A ShardKey’s identity is its typeName alone; the extractor rides
along so the declaring class is the single source of truth, but a node
that only looks entities up can name the same type with
ShardKey.of<CartCommand>('cart') and no extractor. An
extractEntityId passed in options overrides the one on the key.
The remaining shapes are unchanged:
// 1. Class shorthand:const startShardingOptions = StartShardingOptions.create<CartCommand>().withExtractEntityId((m) => m.entityId);cluster.sharding.start('cart', CartActor, startShardingOptions);
// 2. Factory shorthand — when the entity needs constructor args:const startSharding2Options = StartShardingOptions.create<CartCommand>().withExtractEntityId((m) => m.entityId);cluster.sharding.start('cart', () => new CartActor(deps), startSharding2Options);
// 3. Full-form — every setting via the builder:const startSharding3Options = StartShardingOptions.create<CartCommand>() .withTypeName('cart') .withEntityActor(CartActor) .withExtractEntityId((m) => m.entityId) .withNumShards(16) .withRole('cart-host');cluster.sharding.start( startSharding3Options,);cluster.sharding is a memoised facade — repeated reads return the
same ClusterSharding instance. The explicit form
ClusterSharding.get(system, cluster) still works and resolves to
the same object; reach for it only when you need to reference the
class from outside a cluster handle.
The cartRegion is a single ActorRef from the caller’s
perspective. Behind the scenes:
- The region computes a shard from
entityId(default: hash to one of 64 shards). - It asks the coordinator “who owns this shard?”
- If the owner is this node, it spawns the entity (if not already present) and forwards the message.
- If the owner is another node, it forwards over the cluster transport to that node’s region, which does the same.
The four actors at play
Section titled “The four actors at play”| Actor | Role |
|---|---|
| Region | One per node. Routes messages to the right shard’s owner, and decides when local entities — and shards left empty — passivate. |
| Shard | One per shard hosted here, a child of the region. Owns the entity actors: spawns them, stops them, buffers for one on its way out. Goes away itself once it has stood empty for its window, and comes back with the next message. |
| Coordinator | One per cluster (singleton, on the leader). Decides which node owns each shard. Handles rebalancing on membership changes. |
| Entity | One per entityId. A child of its shard, on whichever node currently owns the shard the ID hashes to. |
That gives entities a real path —
/system/cluster/sharding/region-counter/shard-7/entity-user-42
— which is what makes a shard addressable at all. See
introspection for listing shards and
getting refs to them.
The region is the subject of this page; the coordinator’s decisions have pages of their own — see the allocation strategy and rebalance for the mechanics.
Knowing which entity you are
Section titled “Knowing which entity you are”An entity nearly always needs the id it was routed by — to name its journal stream, its pub/sub topic, or a row in someone else’s database. It reads that off itself:
class CartEntity extends Actor<CartCommand> { override preStart(): void { this.log.info(`cart ${this.entityId} woke up in shard ${this.entity.shardId}`); }}this.entityId | The value extractEntityId returned, unchanged. |
this.entity | The same id plus typeName and shardId. |
this.context.entity | The Option form — None when this actor is not an entity. Both getters above throw instead. |
The commonest use is a per-entity persistenceId, which is what gives a
sharded PersistentActor one event
stream per entity rather than one per type:
class CartEntity extends PersistentActor<CartCommand, CartEvent, CartState> { override get persistenceId(): string { return `cart-${this.entityId}`; }}The identity is set on the entity and nowhere else — an entity’s own
children get None, so this.context.entity.nonEmpty answers “am I the
entity?”. Pass the id down explicitly to a child that needs it.
To exercise an entity on its own, hand it an identity directly instead of standing up a cluster around it:
import { ActorOptions } from 'actor-ts';
const cartOptions = ActorOptions.create() .withEntity({ entityId: 'user-42', typeName: 'cart', shardId: 3 });const cart = system.spawn(CartEntity, 'cart-under-test', cartOptions);Why not read it off the path?
Section titled “Why not read it off the path?”.../shard-7/entity-user-42 looks like it carries the id, and for
simple ids slicing off the entity- prefix does work. It is not the
id, though: actor names have a restricted alphabet, so the shard escapes
anything a name cannot carry when it names the child — a code unit
outside [A-Za-z0-9_-.@:+] becomes ~ plus four hex digits, so
user/42 is named entity-user~002F42.
The escape is injective: two different ids never land on one name. That is a correctness property, not a nicety — a shared name means the second entity spawns under a name already taken, and the resulting throw kills the shard along with every other entity living in it.
What it is not is a second way to spell the id. Nothing decodes a path
segment, deliberately, and the escape is not part of the API. The path
is a label; entityId is the value.
Configuration
Section titled “Configuration”ShardingOptionsType<TMessage> — the fields you’ll most often touch:
type ShardingOptionsType<TMessage> = { typeName: string; entityActor: ActorClassOrFactory<TMessage>; entityOptions?: ActorOptions<TMessage>; extractEntityId: (message: TMessage) => string; extractEntityMessage?: (message: TMessage) => unknown; numShards?: number; // default 64 role?: string; // restrict to nodes carrying this role proxy?: boolean; // route-only, no local entities rememberEntities?: boolean; // re-spawn entities on failover (#) passivationIdleMs?: number; // auto-stop idle entities, default 5 min shardPassivationIdleMs?: number; // auto-stop empty shards, follows the above maxEntities?: number; // LRU cap per node};| Field | What it controls |
|---|---|
typeName | A string identifying this sharded type. Different types can coexist in the same cluster (cart, session, order). |
extractEntityId(message) | Pull the entity ID from a message. This is the key that gets hashed to a shard. |
extractEntityMessage(message) | (Optional) If the message envelope contains routing info plus a payload, this strips the envelope before the entity sees it. Defaults to the message as-is. |
numShards | How many shards the entity space is divided into. 64 is fine for most clusters; bump to 1000 for very large clusters (>50 nodes). |
role | Only members with this role host shards of this type. Useful for placing compute-heavy entities on dedicated nodes. |
proxy | This node forwards messages to the region but never hosts entities locally. Used for client nodes in an asymmetric cluster. |
rememberEntities | Persist the set of active entity IDs. After a coordinator failover (or full cluster restart), those IDs are spawned eagerly so messages don’t have to recreate the entire fleet. |
passivationIdleMs | Stop an entity after this much idle time. Frees memory; next message for the same ID re-creates the entity. Defaults to 5 minutes; 0 disables it. |
shardPassivationIdleMs | Stop a shard after it has stood empty this long. Unset it follows passivationIdleMs; 0 keeps empty shards resident while entities still passivate. |
maxEntities | Per-node cap. When exceeded, the LRU entity is passivated. |
The defaults are sensible for small clusters. For production, you
usually want rememberEntities: true and a passivationIdleMs
matched to your traffic pattern.
Five of these are also HOCON keys — number-of-shards,
remember-entities, passivation-idle, shard-passivation-idle and
max-entities — as are the coordinator’s rebalance-interval and
hand-off-timeout. They set the node-wide baseline for every sharded
type and sit under whatever start(...) passes explicitly, so a
per-type value in the builder still wins. See
Configuration.
actor-ts.sharding { number-of-shards = 128 passivation-idle = 2 minutes max-entities = 50000}Passivation
Section titled “Passivation”Passivation stops an idle entity to free memory; the next message for the same ID re-creates it. However it is triggered, the region buffers messages that arrive while the entity is stopping and replays them to the fresh incarnation, so nothing is lost.
The default idle window is 5 minutes. Set passivationIdleMs — in
code or via actor-ts.sharding.passivation-idle — to change it, or 0
to switch it off and keep every entity resident for the lifetime of its
node.
Automatic — when passivationIdleMs elapses with no traffic, or
maxEntities is exceeded (the least-recently-used entity is
evicted), the entity is stopped. Nothing is sent to the entity and no
acknowledgment is involved. The decision is the region’s, because
only it sees traffic across every shard on this node — which is why
maxEntities is a per-node cap, not a per-shard one — and the shard
that owns the entity carries it out.
Manual (graceful) — an entity asks to be passivated by sending a
Passivate to its parent, which is its shard. Passivate carries a
stop-message that gets forwarded back to the entity, so the entity
decides exactly when and how it shuts down (finish in-flight work,
flush state, then terminate). Using PoisonPill as the stop-message
terminates the entity as soon as it comes back:
import { match } from 'ts-pattern';import { Passivate, PoisonPill, Actor } from 'actor-ts';
type AddCommand = { entityId: string; kind: 'add'; sku: string };type CheckoutCommand = { entityId: string; kind: 'checkout' };type CartCommand = AddCommand | CheckoutCommand;
class CartActor extends Actor<CartCommand> { private items: string[] = [];
override onReceive(command: CartCommand): void { match(command) .with({ kind: 'add' }, (c) => this.onAdd(c)) .with({ kind: 'checkout' }, () => this.onCheckout()) .exhaustive(); }
private onAdd(command: AddCommand): void { this.items.push(command.sku); }
// Done with this cart — ask the region to passivate us. private onCheckout(): void { this.context.parent.forEach((parent) => parent.tell(new Passivate(PoisonPill.instance, this.self), this.self)); }}Shards passivate too
Section titled “Shards passivate too”Passivating every entity in a shard used to leave the shard itself
behind — a live actor holding an empty map. Nothing stopped it short of
a rebalance, and because entity IDs spread over the hash space, a
long-running node eventually touched every shard and kept all of them.
With numShards = 64 that is 64 idle actors with nothing in them.
So a shard that has stood empty for shardPassivationIdleMs is stopped
as well. The region keeps ownership — only the actor goes — so the
shard stays routable and the next message for it re-creates the shard
and the entity together, with no coordinator round trip. Unset, the
window follows passivationIdleMs, which is usually what you want: a
shard is empty precisely because its entities went idle.
const shardingOptions = StartShardingOptions.create<CartCommand>() .withTypeName('cart') .withEntityActor(CartActor) .withExtractEntityId((command) => command.entityId) .withPassivationIdleMs(120_000) // Shards are cheap to keep and cost a respawn to lose — outliving their // entities by a while is a reasonable trade on a busy node. .withShardPassivationIdleMs(600_000);Set shardPassivationIdleMs: 0 to keep empty shards resident while
entities still passivate. The region and the coordinator are unaffected
either way — they run for the lifetime of the node.
Only an empty shard is ever stopped, so there is no entity state at
stake, and messages that arrive while a shard is stopping are buffered
and replayed exactly as they are for an entity. A shard ref stays valid
across the gap too — see
introspection, where ShardInfo
also reports a resident flag that tells a passivated shard apart from
one that is merely empty.
Rebalancing
Section titled “Rebalancing”When the cluster topology changes (a node joins or leaves), the coordinator runs a rebalance pass:
- Compute the new shard-to-node assignment from the active allocation strategy (default: hash mod regions).
- For each shard that moved, tell the old region to hand off the shard.
- The old region stops its entities (which may persist their state), tells the coordinator “handoff complete,” and stops routing for that shard.
- The new region spawns entities for that shard on demand as messages arrive.
The handoff isn’t instant — buffered messages wait for the “handoff complete” signal before being forwarded to the new owner. This avoids messages racing past a half-moved entity.
See Rebalance for the full protocol.
State across failover
Section titled “State across failover”Sharded entities are subject to the same restart semantics as any other actor — when an entity moves to a new node, the new instance starts with a clean slate.
For state that should survive:
PersistentActor— the entity persists events to a journal; on a fresh node, it replays the journal at startup. See PersistentActor.DurableStateActor— simpler: persist the current state snapshot; restore on restart. See DurableState.DistributedData— for state that should be readable from any node (not just the entity’s current host), use a CRDT in the DD replicator instead.
Without one of these, sharding gives you placement and routing, not durability.
When to reach for sharding
Section titled “When to reach for sharding”Three good fits:
- Per-user / per-tenant state that’s too much for one node to hold but doesn’t need to be readable from everywhere simultaneously.
- Per-entity workflows — sagas, order processing, long-running coordinators — that benefit from per-key serialization.
- Hotspots that follow keys — a streaming pipeline where each user’s events should be processed in order on a single actor.
When NOT to use sharding
Section titled “When NOT to use sharding”Where to next
Section titled “Where to next”- Introspection — list the shards, get a ref to one shard or one entity, follow the map.
- Allocation strategy — default hash-mod-regions, custom strategies.
- Rebalance — the handoff protocol.
- Remember entities — persistent entity-registry for fast recovery.
- Singleton overview — for one-actor-cluster-wide.
- Sharded daemon process — fixed-count workers distributed by sharding.
The ClusterSharding and
ShardRegion API references cover
the full surface, including the per-node region actor.
