Skip to content
English

Refs across nodes

In a single-node system, an ActorRef is a direct in-memory handle. In a clustered system, an ActorRef can point at an actor on any node — and the same tell works the same way:

const remote: ActorRef<Message> = /* ref to an actor on a different node */;
remote.tell({ kind: 'do-it' });
// → serialized, sent over the cluster transport, delivered to the remote mailbox

The framework hides the network layer. But understanding how helps when something goes wrong (messages disappearing, latency spiking, refs failing to resolve).

A local-only ref’s path looks like:

actor-ts://my-app/user/api/sessions/user-42

A cluster-aware ref includes the host:port of its owning node:

actor-ts://my-app@10.0.0.5:2552/user/api/sessions/user-42
SegmentWhat it is
actor-ts://The scheme — same as local paths.
my-appThe cluster’s system name.
@10.0.0.5:2552The node’s address, assigned at Cluster.join time.
/user/api/sessions/user-42The actor path inside that node, identical in shape to a single-node path.

The host fragment tells the local runtime which node hosts this actor. When you tell a remote ref, the framework:

  1. Reads the host:port from the ref’s path.
  2. Looks up the cluster transport’s connection to that node.
  3. Serializes the message + the ref’s local-path segments.
  4. Writes a wire frame.
  5. Receiving node deserializes; locally resolves the path segments to a real actor; tells the local ref.

Inside a message, an ActorRef field serializes as the path-with-host string. On the receiving node, the deserializer reconstructs a RemoteActorRef pointing back at the original node:

type GetMessage = {
kind: 'get';
replyTo: ActorRef<number>; // ← will serialize as a path string
};
remoteRegistry.tell({
kind: 'get',
replyTo: this.context.self, // → "actor-ts://my-app@10.0.0.3:2552/user/asker"
});

The receiving node sees replyTo as a RemoteActorRef pointing at 10.0.0.3:2552/.../asker. Calling tell on it goes through the same encoding-and-transport machinery in reverse.

This means request/response between actors on different nodes just works — the reply travels back to the original asker over the cluster transport.

A remote ref’s path is the path of the actor it points at, so it is meaningful in logs and safe to use as a map key:

remoteWorker.path.toString(); // "actor-ts://my-app/user/worker"

ActorRef.equals compares that rendering — and a path carries the system name, not the node address. Every member of a cluster shares one system name, so two refs to the same path on different nodes still compare equal:

const onNodeA = /* ref to /user/worker on 10.0.0.5 */;
const onNodeB = /* ref to /user/worker on 10.0.0.6 */;
onNodeA.equals(onNodeB); // true — same path, and that is all equals() sees

So equals answers “same actor path?”, not “same actor”. When you need node identity — deduplicating routees, tracking which member registered a service — compare the refs’ toString(), which is node-qualified, or keep the NodeAddress alongside the ref.

The transport (TCP by default, in-memory in tests) carries:

  • Cluster control traffic — gossip, heartbeats, downing signals.
  • Envelope traffic — your tells wrapped in a routing envelope.

Both share the same TCP connection per peer pair. The framework multiplexes them; you don’t see this distinction.

See Transports for the TCP and in-memory implementations.

import { ActorSelection } from 'actor-ts';
const remote = await system.actorSelection(
'actor-ts://my-app@10.0.0.5:2552/user/api/sessions/user-42',
).resolveOne(5_000);
remote.tell({ kind: 'do-it' });

actorSelection parses the path-with-host; resolveOne returns a RemoteActorRef you can tell. Useful when:

  • The target’s location is known by convention (a well-known sharding path or singleton proxy).
  • A message contains a path string (e.g., from an HTTP request).

For routine cluster work, you’d usually hold a ref obtained from the cluster’s own machinery (sharding region, singleton proxy, event-stream subscriber) — actorSelection is the lookup escape hatch.

What happens when the remote node disappears

Section titled “What happens when the remote node disappears”
const remote: ActorRef = /* on a node that just left the cluster */;
remote.tell({ kind: 'do-it' });
// → message routes to dead letters; sender doesn't see the failure

tell to a remote ref on a stopped/unreachable node:

  1. The framework tries to write to the transport.
  2. The transport sees no live connection (or one in half-closed state).
  3. The message is dropped to dead letters.

Two ways to detect this:

  • context.watch(remoteRef) — receive a Terminated notification when the remote actor stops or the remote node goes unreachable+downed. The Terminated.addressTerminated flag distinguishes “actor stopped” from “node lost.”
  • Subscribe to cluster eventsMemberRemoved / MemberUnreachable for the host’s address tells you the whole node is gone.

See Death watch for the per-actor variant.

Messages crossing the wire need to be serializable. The framework’s SerializationExtension handles this — JSON by default, CBOR when configured.

{
kind: 'place',
order: { items: ['book-1'], total: 19.99 },
replyTo: this.context.self, // ← ref serializes as path string
}

Plain values (strings, numbers, arrays, plain objects) serialize trivially. Things that don’t survive:

  • Functions / closures — can’t cross a process boundary.
  • Class instances with methods — methods are lost; only data fields survive.
  • Map / Set — JSON serializes them as {} (no entries). Use plain objects or arrays.
  • Symbols, BigInts (without serializer registration).

See Messages for the immutability

  • wire-format conventions.

Rough numbers:

  • Local tell (same actor system) — 50-200 ns.
  • Cluster-local tell (loopback transport) — sub-millisecond.
  • Cluster tell over LAN — 0.5-2 ms for small messages.
  • Cluster tell over WAN — bounded by network RTT.

The framework batches envelopes opportunistically — many tells in quick succession share TCP writes when possible.

The RemoteActorRef API reference covers the wire ref implementation.