Phones go into tunnels and laptops close their lids. Offline & sync is what the SDK does about it: messages the user sends while the connection is down are kept in an outbox and sent when it comes back, and everything that happened in the conversation while they were away (new messages, but also edits, deletes, reactions and moderation) is caught up for you.
Where this fits: your app already sends messages and listens for events. You don't call anything to get this. This page explains what happens, the states a message can be in, and the few events worth listening to.
The outbox
Every sendMessage() goes through the outbox. Most messages are in it for
a single round trip and you never notice. It matters when the connection
drops:
| Status | Meaning | What happens next |
|---|---|---|
pending | Waiting for a connection. | Sent automatically on reconnect, oldest first. |
sending | On the wire right now. | Leaves the outbox once the server saves it. |
failed | The server refused it (muted, too large, blocked by moderation…). | Nothing, until you call retry() or cancel(). |
A message the server saved leaves the outbox and arrives as a normal
message event, like any other.
Every entry keeps the clientMessageId it was first sent with, and resends
use the same one. The server stores a message once per
clientMessageId, so a message sent three times over a flaky connection
still appears once.
Show pending and failed messages
In your app:
chat.on('outboxChanged', (entries) => {
// Oldest first. Render these under the real messages, greyed out.
renderPending(entries.filter((e) => e.room === currentRoom));
});
// A failed message has an error you can show.
for (const entry of chat.outbox?.list(currentRoom) ?? []) {
if (entry.status === 'failed') console.log(entry.error?.code); // e.g. 'MUTED'
}
await chat.outbox?.retry(entry.clientMessageId); // resend, same id
await chat.outbox?.cancel(entry.clientMessageId); // false if it's already on the wirelivqeno.chat!.on('outboxChanged', (entries) => setPending(entries));
await livqeno.chat!.outbox?.retry(clientMessageId);chat.outbox.changes.listen((entries) => setState(() => pending = entries));
await chat.outbox.retry(clientMessageId);
await chat.outbox.cancel(clientMessageId);With the React UI kit, useConversation() already merges outbox entries
into its message list as optimistic rows. See
UI components.
What sendMessage() resolves and rejects with
The promise reflects this attempt:
- It resolves when the server has saved the message.
- If the connection failed, it rejects with a connection error that has
error.queued === true. The message is still in the outbox aspendingand will go out on reconnect. Don't show "failed to send" for these. - If the server refused the message, it rejects with a typed error
(see Moderation) and the entry is
failed.
try {
await chat.sendMessage({ text });
} catch (error) {
if (isLivqenoChatError(error) && error.queued) return; // will be sent later
showError(error);
}Keep the outbox across reloads
By default, the outbox keeps unsent messages in memory, using
MemoryOutboxStorage. That's enough to survive a reconnect, but not a page
reload or an app restart. To keep unsent messages across those too, give it
real storage:
import { createChatClient, LocalStorageOutboxStorage } from '@livqeno/chat';
const chat = createChatClient({
...grant,
outbox: { storage: new LocalStorageOutboxStorage() }, // key: 'raven-chat-outbox'
});Implement OutboxStorage: two methods over the whole list.
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { OutboxStorage } from '@livqeno/chat';
const storage: OutboxStorage = {
async load() {
const raw = await AsyncStorage.getItem('raven-chat-outbox');
return raw ? JSON.parse(raw) : [];
},
save: (entries) => AsyncStorage.setItem('raven-chat-outbox', JSON.stringify(entries)),
};
const chat = createChatClient({ ...grant, outbox: { storage } });Implement LivqenoOutboxStorage (for example with shared_preferences) and
pass it to the constructor:
final chat = LivqenoChat(token: token, apiUrl: apiUrl, outboxStorage: MyOutboxStorage());Entries record which user queued them, so a storage shared by two accounts on one device never sends one user's messages as the other.
Pass outbox: false to turn the outbox off. A failed send is then rejected
and forgotten, as before Phase 3.
The outbox only queues the send. Upload attachments first; a queued
message can carry the attachmentId of a finished upload.
Catching up after a reconnect
When the connection comes back, the SDK re-joins your rooms and then:
- Replays missed messages from history, oldest first, through the
normal
messageevent. Nothing is delivered twice. - Replays everything else from the conversation's event log: edits
(
messageUpdated), deletes (messageDeleted), reactions, pins, bans and mutes, in the order they happened. - Sends the outbox.
Live events that arrive during the catch-up are held and delivered after it, so nothing new overtakes something older.
Listen for recovered if you want to know when it's done:
chat.on('recovered', (summary) => {
summary.recovered; // missed messages delivered
summary.events; // missed edits/deletes/reactions/moderation applied
summary.resynced; // rooms whose history had to be re-read (see below)
summary.gap; // true if something couldn't be recovered
summary.errors; // rooms whose catch-up didn't finish; retried next reconnect
});Ordering: seq and eventSeq
Every message has a seq: its position in the conversation, strictly
increasing with no gaps. It's a string, because it can grow past what a
JavaScript number holds exactly. Compare with compareSeq(), never with
< on the strings ("10" < "9" is true):
import { compareSeq } from '@livqeno/chat';
messages.sort((a, b) => compareSeq(a.seq, b.seq));Behind the scenes every conversation event (message, edit, delete,
reaction, moderation) also carries an eventSeq. The SDK uses it to apply
events in order, drop duplicates, and spot holes. If an event goes missing
while connected, the SDK waits briefly (300 ms by default, gapFillDelayMs)
for it to arrive late, then fetches it from the event log. You don't need
to handle any of this.
History also accepts afterSeq and beforeSeq:
const page = await chat.messages.list({ afterSeq: '41' });resync: when you were away too long
The event log keeps 7 days. A client that was away longer can't be caught
up event by event, so the SDK re-reads the newest page of history and emits
resync. If your app keeps its own copy of the conversation, replace it:
chat.on('resync', ({ roomId, page }) => {
// page.data is newest first, like messages.list()
setMessages(roomId, [...page.data].reverse());
});The React hooks and livqeno_chat handle this for you.
Common mistakes
| Mistake | What you'll see | Fix |
|---|---|---|
| Showing an error for every rejected send | "Failed to send" on messages that later arrive. | Check error.queued first. |
Retrying with a fresh clientMessageId | Duplicate messages. | Reuse the id, or let the outbox retry. |
Sorting by seq as strings | Message 10 shows before message 9. | Use compareSeq(). |
Keeping a local list and ignoring resync | Stale edits or deleted messages after a long absence. | Replace the list on resync. |