← All notes

Real-time · Offline · TypeScript

An offline message queue for chat on flaky airport networks

Show it at once, keep the order, never lose anything silently: the outbox pattern behind a real-time chat for airline operations.

Radu Sadovei · · 7 min read

At SITA I built a real-time communication app for airline operations teams: the people who coordinate a flight from the ground. It started as a React Native MVP, then became an Angular PWA inside SITA Mission Watch, the flight-tracking platform used by 65+ airlines. Selecting a flight on the map opens its conversation, and flight alerts arrive in the same thread.

The hardest part wasn't the chat. It was the network. Airports are full of thick walls, crowded Wi-Fi and handovers between Wi-Fi and 4G. Connections don't just drop; they half-work, stall and come back. A message that silently disappears, or arrives after the one written after it, is not a small UX bug when the conversation is about a diversion.

The answer was an outbox: a small queue between the composer and the network. Messages show Sending… straight away and go out in their original order as soon as the connection returns. This note is the pattern behind it, written framework-agnostic so it fits React, Angular or plain TypeScript, plus a few things I'd add if I built it again.

Three rules

  1. Show it immediately. The message appears in the thread the moment you press Send, marked Sending…. Waiting for the server before showing anything feels broken on a bad connection.
  2. Keep the order. Messages go out one at a time, oldest first. If the first one fails, the second waits. "Divert to LIS" must never arrive after "Cancel that".
  3. Never lose anything silently. A message is either sent, still trying, or clearly marked as not sent with a way to retry.
Message lifecycle in an offline-tolerant chatA composed message appears immediately in the conversation with a sending state and joins a queue. The queue sends one message at a time, oldest first. On success it is marked sent. On a network failure the queue pauses, keeps the order, and resumes when the connection is back. After repeated failures the message is marked as not sent, with a retry action.Composeduser hits SendSending…shown at onceSentserver confirmedWaitingoffline, order keptnetwork errorback onlineNot senttap to retrytoo many attemptsQueueone at a time,oldest first
The message is on screen instantly. The network only decides when it says “sent”.

The outbox

The core is a queue that processes one item at a time and stops at the first failure. Stopping is the whole point: it's what keeps the order.

outbox.ts
type Status = "sending" | "sent" | "failed";

export type Outgoing = { id: string; roomId: string; body: string; status: Status; attempts: number };

export class Outbox {
  private queue: Outgoing[] = [];
  private running = false;

  constructor(
    private send: (message: Outgoing) => Promise<void>,
    private onChange: (message: Outgoing) => void,
    private maxAttempts = 5,
  ) {}

  add(roomId: string, body: string) {
    const message: Outgoing = { id: crypto.randomUUID(), roomId, body, status: "sending", attempts: 0 };
    this.queue.push(message);
    this.onChange(message); // shown in the thread right away
    void this.flush();
    return message;
  }

  // Sends oldest first and stops at the first failure, so order is always preserved
  async flush() {
    if (this.running) return;
    this.running = true;
    try {
      while (this.queue.length) {
        const message = this.queue[0];
        try {
          await this.send(message);
          message.status = "sent";
          this.queue.shift();
          this.onChange(message);
        } catch {
          message.attempts++;
          if (message.attempts >= this.maxAttempts) {
            message.status = "failed";
            this.queue.shift();
            this.onChange(message);
            continue;
          }
          return; // keep it at the head and wait for the network
        }
      }
    } finally {
      this.running = false;
    }
  }

  retry(message: Outgoing) {
    message.status = "sending";
    message.attempts = 0;
    this.queue.push(message);
    this.onChange(message);
    void this.flush();
  }
}

Whether a message that finally fails should block the ones after it is a product decision. My advice: let the queue move on and mark the failed one clearly, with a retry action. A queue that stays stuck makes the whole app look broken.

When to try again

navigator.onLine is a hint, not a fact. It says the device has a network, not that the server is reachable. Treat three things as reasons to flush again: the browser's online event, the app coming back to the foreground, and a timer with backoff for the cases where neither event fires.

retry.ts
export function keepFlushing(outbox: Outbox) {
  let delay = 1_000;
  let timer = 0;

  const flush = async () => {
    window.clearTimeout(timer);
    await outbox.flush();
    // Back off while things keep failing, up to 30 s, then try again anyway
    delay = Math.min(delay * 2, 30_000);
    timer = window.setTimeout(flush, delay);
  };
  const reset = () => {
    delay = 1_000;
    void flush();
  };

  window.addEventListener("online", reset);
  document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") reset();
  });
  void flush();
}

Don't send twice

Retries create a classic trap: the request reached the server, but the response got lost on the way back. Retry, and you've posted the message twice. The fix is an idempotency key: give every message its own id when it's created and send it with every attempt, so the server can recognise a repeat.

Matrix, the open protocol behind the app, has this built in. Messages are sent with a client-chosen transaction id (PUT /rooms/{roomId}/send/m.room.message/{txnId}), and the server treats a repeat of the same id as the same message. Reuse the outbox id as the transaction id and retries become safe.

send.ts
// The outbox id doubles as Matrix's transaction id, so a retried request can't create a duplicate
const send = (message: Outgoing) =>
  matrix.sendEvent(message.roomId, "m.room.message", { msgtype: "m.text", body: message.body }, message.id);

What the user sees

  • Sending… in a quieter style next to the message, not a spinner over the whole thread.
  • A connection banner only after a few seconds offline. Flashing it on every short drop trains people to ignore it.
  • Not sent · Retry for the rare message that gives up, right where the message is.
  • Screen readers need the status too: announce "not sent" through a polite live region instead of only showing it in red.

If it has to survive a reload

The version above lives in memory, which covers the common case: a bad connection while the app is open. If people close the app with unsent messages, persist the queue (IndexedDB is the natural place in a PWA) and rebuild it on start. Keep the ids, and the idempotency key keeps protecting you from duplicates.

The takeaway

Offline support isn't a feature you add at the end. It's a decision about where the truth lives. With an outbox, the truth is on the device first and on the server soon after, and the interface is honest about the gap between the two.