Wire Protocol
NoLag communicates over WebSocket using MessagePack-encoded binary frames. Every message is a map (key-value object) with a type field that identifies what kind of message it is. There is no custom binary framing; the entire message is a single MessagePack-encoded map.
Transport
- Protocol: WebSocket (binary frames)
- Encoding: MessagePack with
pack_str: from_binary - Endpoint:
wss://broker.nolag.app/ws - Heartbeat: Empty binary packet (0 bytes) every 30 seconds, both directions
- Max message size: 256 KB (plan-dependent)
- Rate limit: 50 messages/second per connection
Connection Lifecycle
1. Authentication
After the WebSocket opens, the client sends an auth message immediately:
// Client sends after WebSocket opens
{
"type": "auth",
"token": "at_live_<key_id>.<secret>",
"reconnect": false,
"projectId": "proj_123"
}The server responds within 10 seconds:
{
"type": "auth",
"success": true,
"actorTokenId": "actor_123"
} If reconnect: true, the server restores previous subscriptions and returns them in a restoredSubscriptions array so the client can rebuild its local state.
2. Heartbeat
Both client and server send an empty binary packet (0 bytes) every 30 seconds as a keep-alive. This is not a MessagePack message, just an empty WebSocket binary frame.
Client to Server Messages
subscribe
{
"type": "subscribe",
"topic": "chat/general/messages",
"qos": 1,
"filters": ["user_123", "user_456"],
"loadBalance": false,
"loadBalanceGroup": "worker-pool-1"
} Topics use human-readable slugs in the format app/room/topic. The server resolves these to internal identifiers. Filters narrow delivery to specific entities within the topic. Load balancing distributes messages across clients in the same group (only one client receives each message).
unsubscribe
{
"type": "unsubscribe",
"topic": "chat/general/messages"
}publish
{
"type": "publish",
"topic": "chat/general/messages",
"data": { "text": "hello world", "sender": "user_12" },
"qos": 1,
"echo": true,
"filter": "user_123"
}echo: false prevents the message from being delivered back to the sender. The filter field routes the message only to subscribers who have that filter value in their subscription.
setFilters
{
"type": "setFilters",
"topic": "chat/general/messages",
"filters": ["user_789"]
} Replaces the entire filter set for a topic. An empty array switches to wildcard mode (receive all messages). Max 100 filters per topic. Filters cannot contain /, #, or +.
presence
{
"type": "presence",
"roomId": "general",
"data": { "status": "online", "currentPage": "dashboard" }
}getPresence
{
"type": "getPresence",
"roomId": "general"
}ack / batchAck
{ "type": "ack", "msgId": "550e8400-..." }Required when the server sends a message with requiresAck: true.
lobbySubscribe / lobbyUnsubscribe
{ "type": "lobbySubscribe", "lobbyId": "active-trips" }Server to Client Messages
message
{
"type": "message",
"topic": "chat/general/messages",
"data": { "text": "hello world", "sender": "user_12" },
"msgId": "550e8400-...",
"requiresAck": true,
"isReplay": false,
"filter": "user_123"
}msgId is present if the message is stored (logging enabled). isReplay: true marks messages replayed after reconnection.
subscribed / unsubscribed
{ "type": "subscribed", "topic": "chat/general/messages", "loadBalance": false }filtersUpdated
{
"type": "filtersUpdated",
"topic": "chat/general/messages",
"filters": ["user_789"]
}presence / presenceList
{
"type": "presence",
"event": "join",
"data": {
"actorTokenId": "actor_123",
"presence": { "status": "online" },
"joinedAt": 1634567890123
}
}lobbySubscribed / lobbyPresence
{
"type": "lobbySubscribed",
"lobbyId": "active-trips",
"presence": {
"room_1": { "actor_123": { "status": "driving" } },
"room_2": { "actor_456": { "status": "waiting" } }
}
}replayStart / replayEnd
{ "type": "replayStart", "count": 247, "oldestTimestamp": "...", "newestTimestamp": "..." }
// ... replayed messages arrive with isReplay: true ...
{ "type": "replayEnd", "replayed": 247 }hydration
{
"type": "hydration",
"topic": "chat/general/messages",
"data": { "recentMessages": [...] }
}Sent on subscribe if a hydration webhook is configured for the topic.
error
{
"type": "error",
"error": "rate_limit_exceeded",
"code": 42910,
"topic": "chat/general/messages"
}| Code | Error | Description |
|---|---|---|
| 42910 | rate_limit_exceeded | Too many messages per second |
| 42920 | monthly_quota_exceeded | Plan message quota reached |
| 42930 | message_too_large | Payload exceeds plan limit |
| - | not_authorized | ACL denied access to topic |
| - | not_subscribed | Publish to a topic not subscribed |
| - | invalid_filter_chars | Filter contains /, #, or + |
disconnect
{ "type": "disconnect", "reason": "token_invalid" }Server-initiated disconnect (e.g. token revoked, revalidation failed).
Delivery Guarantees (QoS)
QoS is specified per-message on both subscribe and publish. Default is 1.
- QoS 0: At-most-once. Best effort, no acknowledgement.
- QoS 1: At-least-once. Server acknowledges delivery, may duplicate.
- QoS 2: Exactly-once. Guaranteed single delivery.
Why MessagePack?
MessagePack encodes the same data as JSON in 30-50% fewer bytes. For real-time applications pushing thousands of messages per second, this reduction in payload size translates directly to lower bandwidth costs and faster delivery.
- Binary format, no parsing ambiguity
- Native support for binary data (no Base64 overhead)
- Schema-less, same flexibility as JSON
- Implementations available in every major language
Next Steps
- Quality of Service - configure per-message delivery guarantees
- Filters - narrow message delivery within a topic
- Presence - track who is online
- SDKs - the protocol is handled for you in every SDK