livqenoDocs

ChatOffline & Sync

Offline & Sync

Messages typed offline are queued and sent once. Edits, deletes and reactions you missed are caught up for you.

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:

StatusMeaningWhat happens next
pendingWaiting for a connection.Sent automatically on reconnect, oldest first.
sendingOn the wire right now.Leaves the outbox once the server saves it.
failedThe 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 wire

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 as pending and 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'
});

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:

  1. Replays missed messages from history, oldest first, through the normal message event. Nothing is delivered twice.
  2. Replays everything else from the conversation's event log: edits (messageUpdated), deletes (messageDeleted), reactions, pins, bans and mutes, in the order they happened.
  3. 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

MistakeWhat you'll seeFix
Showing an error for every rejected send"Failed to send" on messages that later arrive.Check error.queued first.
Retrying with a fresh clientMessageIdDuplicate messages.Reuse the id, or let the outbox retry.
Sorting by seq as stringsMessage 10 shows before message 9.Use compareSeq().
Keeping a local list and ignoring resyncStale edits or deleted messages after a long absence.Replace the list on resync.

Next steps