Objects
LiveObjects enables you to store shared data as “objects” on a channel, allowing your application data to be synchronized across multiple users and devices in realtime. This document explains the key concepts you need to know when working with objects.
Object Types
LiveObjects provides specialized object types to model your application state. These object types are designed to be conflict-free and eventually consistent, meaning that all operations on them are commutative and converge to the same state across all clients.
LiveMap Object
LiveMap is a key/value data structure similar to a dictionary or JavaScript Map :
- Keys must be strings
- Values can be primitive types or references to other objects
- Supports
set
andremove
operations - Concurrent updates to the same key are resolved using last-write-wins (LWW) semantics
// Create a LiveMap
const userSettings = await channel.objects.createMap();
// Set primitive values
await userSettings.set('theme', 'dark');
await userSettings.set('notifications', true);
CopyCopied!
Primitive Types
LiveMap supports the following primitive types as values:
string
number
boolean
bytes
LiveCounter Object
LiveCounter is a numeric counter type:
- The value is a double-precision floating-point number
- Supports
increment
anddecrement
operations
// Create a LiveCounter
const visitsCounter = await channel.objects.createCounter();
// Increment the counter
await visitsCounter.increment(1);
CopyCopied!
Root Object
The root object is a special LiveMap
instance which:
- Implicitly exists on a channel and does not need to be created explicitly
- Has the special objectId of
root
- Cannot be deleted
- Serves as the entry point for accessing all other objects on a channel
Access the root object using the getRoot()
function:
// Get the Root Object
const root = await channel.objects.getRoot();
// Use it like any other LiveMap
await root.set('app-version', '1.0.0');
CopyCopied!
Reachability
All objects must be reachable from the root object (directly or indirectly). Objects that cannot be reached from the root object will eventually be deleted .
When an object has been deleted, it is no longer usable and calling any methods on the object will fail.
In the example below, the only reference to the counterOld
object is replaced on the root
. This makes counterOld
unreachable and it will eventually be deleted.
// Create a counter and reference it from the root
const counterOld = await channel.objects.createCounter();
await root.set('myCounter', counterOld);
// counterOld will eventually be deleted
counterOld.on('deleted', () => {
console.log('counterOld has been deleted and can no longer be used');
});
// Create a new counter and replace the old one referenced from the root
const counterNew = await channel.objects.createCounter();
await root.set('myCounter', counterNew);
CopyCopied!
Composability
LiveObjects enables you to build complex, hierarchical data structures through composability.
Specifically, a LiveMap:/docs/liveobjects/map can store references to other LiveMap
or LiveCounter
object instances as values. This allows you to create nested hierarchies of data.
// Create LiveObjects
const profileMap = await channel.objects.createMap();
const preferencesMap = await channel.objects.createMap();
const activityCounter = await channel.objects.createCounter();
// Build a composite structure
await preferencesMap.set('theme', 'dark');
await profileMap.set('preferences', preferencesMap);
await profileMap.set('activity', activityCounter);
await root.set('profile', profileMap);
// Resulting structure:
// root (LiveMap)
// └── profile (LiveMap)
// ├── preferences (LiveMap)
// │ └── theme: "dark" (string)
// └── activity (LiveCounter)
CopyCopied!
It is possible for the same object instance to be accessed from multiple places in your object tree:
// Create a counter
const counter = await channel.objects.createCounter();
// Create two different maps
const mapA = await channel.objects.createMap();
const mapB = await channel.objects.createMap();
await root.set('a', mapA);
await root.set('b', mapB);
// Reference the same counter from both maps
await mapA.set('count', counter);
await mapB.set('count', counter);
// The counter referenced from each location shows the same
// value, since they refer to the same underlying counter
mapA.get('count').subscribe(() => {
console.log(mapA.get('count').value()); // 1
});
mapB.get('count').subscribe(() => {
console.log(mapB.get('count').value()); // 1
});
// Increment the counter
await counter.increment(1);
CopyCopied!
It is also possible that object references form a cycle:
// Create two different maps
const mapA = await channel.objects.createMap();
const mapB = await channel.objects.createMap();
// Set up a circular reference
await mapA.set('ref', mapB);
await mapB.set('ref', mapA);
// Add one map to root (both are now reachable)
await root.set('a', mapA);
// We can traverse the cycle
root.get('a') // mapA
.get('ref') // mapB
.get('ref'); // mapA
CopyCopied!
Metadata
Objects include metadata that helps with synchronization, conflict resolution and managing the object lifecycle.
Object IDs
Every object has a unique identifier that distinguishes it from all other objects.
Object IDs follow a specific format:
type:hash@timestamp
CopyCopied!
For example:
counter:J7x6mAF8X5Ha60VBZb6GtXSgnKJQagNLgadUlgICjkk@1734628392000
CopyCopied!
This format has been specifically designed to ensure uniqueness in a globally distributed system and includes:
- type: the object type (either
map
orcounter
) - hash: a base64 string encoded hash derived from the initial value of the object and a random nonce
- timestamp: a Unix millisecond timestamp denoting the creation time of the object
Tombstones
Tombstones are markers indicating an object or map entry has been deleted.
- A tombstone is created for an object when it becomes unreachable from the root object.
- A tombstone is created for a map entry when it is removed
Tombstones protect against lagging clients from re-introducing a deleted value, ensuring all clients eventually converge on the same state. They are eventually garbage collected after a safe period of time.
Timeserials
When an operation message is published it is assigned a unique logical timestamp called a “timeserial”.
This timeserial is stored on map entries in order to implement last-write-wins conflict resolution semantics.
Additionally, all objects store the timeserial of the last operation that was applied to the object. Since Ably operates fully independent data centers, these timeserials are stored on a per-site basis.
Timeserial metadata is used for internal purposes and is not directly exposed in client libraries. However, it can be viewed using the REST API .