Skip to content

Serialization overview

Two scenarios require turning JS values into bytes:

  1. Cluster wire — a tell to a remote actor needs the message serialized for TCP transmission.
  2. Persistence — events / snapshots / durable state stored on disk or in a journal.

The framework’s Serializer interface is the abstraction. Two built-ins ship:

SerializerFormatWhen
JsonSerializerJSONDefault. Human-debuggable, widely understood.
CborSerializerCBOR (RFC 8949)Compact, binary-native, faster.

Plus an extension point: implement Serializer<T> for Protobuf, Avro, MessagePack, etc.

Sender side:
tell(remoteRef, msg)
→ SerializationExtension picks serializer for typeof(msg)
→ serializer.toBinary(msg) → Uint8Array
→ wrapped with serializerId + manifest + bytes
→ cluster transport sends over the wire
Receiver side:
cluster transport receives bytes
→ SerializationExtension picks serializer by serializerId
→ serializer.fromBinary(bytes, manifest) → reconstituted value
→ delivered to actor's onReceive

The serializerId + manifest tell the receiver which serializer to use + what type to reconstruct.

interface Serializer<T = unknown> {
readonly id: number; // unique per serializer
readonly name: string; // human-readable
includesManifest: boolean;
manifest(obj: T): string; // type tag
toBinary(obj: T): Uint8Array;
fromBinary(bytes: Uint8Array, manifest: string): T;
}

Small surface — encode, decode, identify. The framework’s SerializationExtension maps classes to serializers (via bind); on serialization, the extension looks up which serializer to use for the value’s class.

const system = ActorSystem.create('my-app');
// → JsonSerializer registered as default
// → Every value serializes as JSON unless a specific serializer is bound

If you do nothing, every value’s bytes are JSON. Plain objects, arrays, strings, numbers — all work. Class instances serialize as plain objects (losing methods).

AspectJSONCBOR
Wire sizeLarger~20-40 % smaller
SpeedSlower than CBOR for binary dataFaster
DebuggingTrivial (text)Requires CBOR-aware tool
Binary fieldsbase64-encoded (wasteful)Native binary
Library supportUniversalSolid in JS land

For most apps, JSON is fine — the perf difference doesn’t matter and the debuggability is valuable.

For bandwidth-sensitive cases (large cluster, lots of events, IoT-style payloads), CBOR wins meaningfully.

See JsonSerializer + CborSerializer for details.

For Protobuf / Avro / etc., implement Serializer<T> and register:

import { SerializationExtensionId } from 'actor-ts';
const ext = system.extension(SerializationExtensionId);
ext.bind(MyEvent, new ProtobufSerializer<MyEvent>(MyEventSchema));

Now every MyEvent value uses Protobuf for serialization across the wire / to journal. Other types still use JSON.

See Custom serializers for the full setup.

The default JSON serializer handles:

  • Plain objects ({ a: 1, b: 'two' }).
  • Arrays.
  • Strings, numbers, booleans, null.
  • Nested combinations of the above.

Cannot serialize:

  • Functions / closures (no method survival).
  • Class instances with methods (methods lost; data fields survive).
  • Map / Set (JSON-stringified as {}).
  • Date (round-trips through string; type info lost).
  • BigInt (throws).
  • Symbols (silently dropped).

CBOR handles a few more (binary Uint8Array natively, Map as a real map), but not functions.

For complex types, convert to plain objects before sending:

// ✗
ref.tell({ when: new Date() });
// ✓
ref.tell({ when: Date.now() });
1. cluster wire — every cross-node tell.
2. PersistentActor.persist(event) — events to the journal.
3. DurableStateActor.persist(state) — state to the store.
4. Snapshot writes — snapshots to the snapshot store.
5. DistributedData — replicated state across the cluster.

In-process tells don’t serialize — the receiver gets the exact in-memory reference. Serialization happens only at process / disk / cluster boundaries.