Skip to main content

Optimistic Updates & Offline Messaging

In real-time chat, latency kills the feel of responsiveness. If a user types a message and waits 200ms to see it appear, it feels broken. The solution is optimistic updates: immediately show the message in the UI before the server acknowledges it, then correct if the server rejects it. Combine this with an offline message queue, and your app works perfectly even on 3G networks or during brief disconnections.

Optimistic updates create a challenge: if the user is offline when they send a message, the message sits in a queue. When they reconnect, those messages must replay in order, and the server's response might differ from what you optimistically rendered (e.g., a timestamp adjustment). We'll manage this by giving each message a temporary client-side ID, then mapping it to the server's ID on ACK.

Optimistic Message Insertion

When the user sends a message, add it to the UI immediately with a temporary ID:

function useOptimisticMessage(channelId) {
const { state, dispatch } = useContext(ChatContext);
const wsClientRef = useRef(null); // From context

const sendMessage = useCallback((text) => {
const clientId = `client_${Date.now()}_${Math.random()}`;
const now = Date.now();

// Optimistic: add to local state immediately
const optimisticMessage = {
id: clientId,
userId: state.currentUser.id,
text,
timestamp: now,
edited: null,
reactions: {},
pending: true, // Mark as pending
sent: false,
};

dispatch({
type: 'ADD_MESSAGE',
payload: {
channelId,
message: optimisticMessage,
},
});

// Send to server asynchronously
wsClientRef.current?.send({
type: 'message',
payload: {
clientId, // Server will echo this back in ACK
channelId,
userId: state.currentUser.id,
text,
timestamp: now,
},
});
}, [channelId, state.currentUser.id, dispatch]);

return { sendMessage };
}

Message State Enum

Track the lifecycle of each message:

const MessageState = {
SENDING: 'sending', // Optimistically added, waiting for ACK
SENT: 'sent', // Server acknowledged (has server ID)
FAILED: 'failed', // Server rejected or timeout
SYNCING: 'syncing', // Offline queue being flushed
};

// In your message object:
{
id: 'msg_12345', // Server ID (stable after ACK)
clientId: 'client_...', // Temporary client ID
state: 'sent',
text: 'Hello',
timestamp: 1717344010000,
pending: false, // Legacy; use `state` instead
}

Handling Server Acknowledgments

When the server ACKs a message, map the client ID to the server ID:

function handleMessageAck(payload) {
const { clientId, messageId, channelId, timestamp } = payload;
// Server says: the message you sent with clientId 'client_xxx'
// has been stored as 'msg_12345' with timestamp 1717344010000

dispatch({
type: 'ACK_MESSAGE',
payload: {
channelId,
clientId,
messageId,
timestamp,
},
});
}

// In reducer:
case 'ACK_MESSAGE': {
const { channelId, clientId, messageId, timestamp } = action.payload;
const channel = state.channels[channelId];
const msgIndex = channel.messages.findIndex(m => m.clientId === clientId);

if (msgIndex !== -1) {
const updatedMessages = [...channel.messages];
updatedMessages[msgIndex] = {
...updatedMessages[msgIndex],
id: messageId,
timestamp, // Use server's timestamp, not client's
state: 'sent',
pending: false,
};

// Remove from offline queue if present
const newQueue = state.offlineQueue.filter(m => m.clientId !== clientId);

return {
...state,
channels: {
...state.channels,
[channelId]: {
...channel,
messages: updatedMessages,
},
},
offlineQueue: newQueue,
};
}
return state;
}

Offline Queue Management

Store messages in a queue while offline and replay on reconnect:

const offlineQueueReducer = (state, action) => {
switch (action.type) {
case 'ENQUEUE_MESSAGE': {
return {
...state,
messages: [...state.messages, action.payload],
};
}
case 'DEQUEUE_MESSAGES': {
// Remove messages that have been ACK'd
const ackedClientIds = new Set(action.payload);
return {
...state,
messages: state.messages.filter(
m => !ackedClientIds.has(m.clientId)
),
};
}
case 'FLUSH_QUEUE': {
return {
...state,
messages: [],
flushedAt: Date.now(),
};
}
default:
return state;
}
};

// Usage in ChatProvider:
const handleConnectionReestablished = () => {
// Replay all queued messages
state.offlineQueue.messages.forEach((msg) => {
wsClientRef.current?.send({
type: 'message',
payload: msg,
});
});
};

Conflict Resolution on Reconnect

If the server rejects a message or a different version arrived while offline, reconcile:

function handleServerRejection(payload) {
const { clientId, reason } = payload;

dispatch({
type: 'REJECT_MESSAGE',
payload: { clientId, reason },
});
}

// In reducer:
case 'REJECT_MESSAGE': {
const { clientId, reason } = action.payload;

// Find message by clientId across all channels
for (const channelId in state.channels) {
const channel = state.channels[channelId];
const msgIndex = channel.messages.findIndex(m => m.clientId === clientId);

if (msgIndex !== -1) {
const updatedMessages = [...channel.messages];
updatedMessages[msgIndex] = {
...updatedMessages[msgIndex],
state: 'failed',
pending: false,
errorReason: reason, // e.g., "Not authorized", "Channel not found"
};

return {
...state,
channels: {
...state.channels,
[channelId]: {
...channel,
messages: updatedMessages,
},
},
// Keep in queue for retry
};
}
}
return state;
}

Rendering Pending Messages with Visual Feedback

Show different UI for pending, sent, and failed messages:

function MessageRow({ message, onRetry }) {
const statusIcon = {
sending: '⏱️',
sent: '✓',
failed: '❌',
};

const statusColor = {
sending: '#999',
sent: '#28a745',
failed: '#dc3545',
};

return (
<div style={{ padding: '8px 12px', marginBottom: '4px' }}>
<div style={{ display: 'flex', gap: '8px', marginBottom: '4px' }}>
<span style={{ color: statusColor[message.state] }}>
{statusIcon[message.state]}
</span>
<span style={{ flex: 1 }}>{message.text}</span>
</div>

{message.state === 'failed' && (
<div style={{ fontSize: '12px', color: '#dc3545' }}>
Failed to send: {message.errorReason}
<button
onClick={() => onRetry(message.clientId)}
style={{
marginLeft: '8px',
fontSize: '12px',
backgroundColor: '#dc3545',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
padding: '2px 6px',
}}
>
Retry
</button>
</div>
)}
</div>
);
}

function MessageList({ messages, onRetry }) {
return (
<div>
{messages.map((msg) => (
<MessageRow
key={msg.clientId || msg.id}
message={msg}
onRetry={onRetry}
/>
))}
</div>
);
}

Detecting and Handling Disconnections

Monitor connection state and inform the user:

function useConnectionStatus() {
const { state, dispatch } = useContext(ChatContext);

useEffect(() => {
const handleOnline = () => {
dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'reconnecting' });
// Trigger a reconnection attempt
};

const handleOffline = () => {
dispatch({ type: 'SET_CONNECTION_STATUS', payload: 'offline' });
};

window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);

return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [dispatch]);

return state.connectionStatus;
}

Key Takeaways

  • Optimistic updates feel instant: Show the message in the UI before the server responds.
  • Client IDs map temporary to permanent state: Each optimistic message gets a temporary ID, replaced on ACK.
  • Offline queue survives page reloads: Persist the queue to localStorage; replay on reconnect.
  • Visual feedback prevents confusion: Show ⏱️ sending, ✓ sent, or ❌ failed next to each message.

Frequently Asked Questions

How do I retry a failed message?

Store the original message object (including clientId and text) in the failed message state. When the user clicks "Retry", send it again with a new clientId, treating it as a fresh send.

What if the user sends offline, closes the browser, and reopens it later?

Persist the offlineQueue to localStorage on every change. On app load, hydrate from localStorage and replay those messages once reconnected.

How do I handle out-of-order message arrivals?

The server should assign monotonic sequence numbers or server-side timestamps. On the client, sort messages by timestamp or sequence number when rendering, not by arrival order. Keep the offlineQueue sorted by clientId or send order.

Should I debounce optimistic updates to avoid UI thrashing?

No; optimistic updates are instant by design. Only debounce the broadcast of typing indicators, not the message inserts.

Further Reading