Offline-First React Kanban: Sync When Online
An offline-first kanban board lets users work uninterrupted even when their network drops. Changes are queued locally; when the connection returns, the app syncs changes with the server and merges conflicts. This pattern is critical for mobile and remote-work scenarios (approximately 12% of users experience network interruptions during a work session, per Chromium telemetry 2026).
Detecting Network Status
Begin by detecting online/offline state:
function useOnlineStatus() {
const [isOnline, setIsOnline] = React.useState(navigator.onLine);
React.useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
function Board() {
const isOnline = useOnlineStatus();
return (
<div className="board">
{!isOnline && <div className="offline-banner">Working offline</div>}
{/* ... board content ... */}
</div>
);
}
navigator.onLine returns the current status; the online and offline events fire when connectivity changes. This provides real-time awareness of network state.
Queuing Changes for Offline Work
When offline, queue changes locally instead of sending them immediately:
function useOfflineQueue() {
const [queue, setQueue] = React.useState([]);
const [isProcessing, setIsProcessing] = React.useState(false);
function enqueue(action) {
setQueue((prev) => [
...prev,
{
id: Date.now().toString(),
action, // { type: 'MOVE_TASK', payload: {...} }
timestamp: Date.now(),
retries: 0,
},
]);
}
async function processQueue() {
if (queue.length === 0 || isProcessing) return;
setIsProcessing(true);
for (const item of queue) {
try {
// Send to server
const response = await fetch('/api/kanban/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.action),
});
if (response.ok) {
// Remove from queue
setQueue((prev) => prev.filter((q) => q.id !== item.id));
} else if (response.status >= 500) {
// Transient error; retry later
item.retries++;
} else {
// Client error (invalid action); discard
console.error('Discarding invalid action:', item.action);
setQueue((prev) => prev.filter((q) => q.id !== item.id));
}
} catch (error) {
console.error('Sync error:', error);
// Network error; stop processing and retry on next reconnect
break;
}
}
setIsProcessing(false);
}
return { queue, enqueue, processQueue };
}
The queue stores actions with metadata (ID, timestamp, retry count). processQueue sends each action to the server and removes it on success. On transient errors (500+), it retries on the next attempt. On client errors, it discards the action.
Triggering Sync on Reconnect
Combine online-status detection with queue processing:
function Board() {
const isOnline = useOnlineStatus();
const { queue, enqueue, processQueue } = useOfflineQueue();
const [tasks, setTasks] = React.useState(initialTasks);
// Sync queue when coming online
React.useEffect(() => {
if (isOnline) {
processQueue();
}
}, [isOnline, processQueue]);
function moveTask(taskId, fromColumn, toColumn) {
const action = {
type: 'MOVE_TASK',
payload: { taskId, fromColumn, toColumn },
};
if (!isOnline) {
// Offline: queue the action
enqueue(action);
} else {
// Online: send immediately and update local state
fetch('/api/kanban/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action),
}).catch((err) => {
console.error('Sync failed:', err);
enqueue(action); // Fall back to queue on error
});
}
// Always update local state optimistically
setTasks((prev) => {
const updated = { ...prev };
const source = [...prev[fromColumn]];
const target = [...prev[toColumn]];
const index = source.findIndex((t) => t.id === taskId);
const [task] = source.splice(index, 1);
target.push(task);
updated[fromColumn] = source;
updated[toColumn] = target;
return updated;
});
}
return (
<div className="board">
{!isOnline && (
<div className="offline-banner">
Working offline. {queue.length} changes queued.
</div>
)}
{isOnline && queue.length > 0 && (
<div className="sync-banner">Syncing {queue.length} changes...</div>
)}
{/* ... board content ... */}
</div>
);
}
The board optimistically updates local state (so the UI feels responsive) and either sends to the server (if online) or queues the action (if offline). When connectivity returns, useEffect triggers processQueue, flushing all pending changes.
Handling Conflicts During Sync
When two users edit the same task offline and both come online, conflicts arise. Implement a simple last-write-wins strategy or three-way merge:
async function syncWithConflictResolution(localTask, action) {
// Fetch current server state
const response = await fetch(`/api/kanban/tasks/${action.payload.taskId}`);
const serverTask = await response.json();
// Compare versions
if (serverTask.version > localTask.version) {
// Server version is newer; merge changes
const merged = mergeTaskChanges(serverTask, localTask, action);
// Send merged version to server
await fetch('/api/kanban/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(merged),
});
} else {
// Local version is current; apply action
await fetch('/api/kanban/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action),
});
}
}
function mergeTaskChanges(server, local, incomingAction) {
// Example: both changed title, but different values
// Pick local version if timestamp is newer
if (local.updatedAt > server.updatedAt) {
return {
...server,
...local,
version: server.version + 1,
};
}
return server;
}
Version numbers (maintained by the server) help detect which state is newer. On conflict, a merge function reconciles changes. For complex conflicts, implement operational transformation (OT) or CRDT (Conflict-free Replicated Data Type) algorithms (see the real-time collaboration article).
Persisting Offline Queue
Save the queue to localStorage so it survives browser restarts:
function useOfflineQueue() {
const [queue, setQueue] = React.useState(() => {
const saved = localStorage.getItem('offline-queue');
return saved ? JSON.parse(saved) : [];
});
function enqueue(action) {
setQueue((prev) => {
const updated = [
...prev,
{
id: Date.now().toString(),
action,
timestamp: Date.now(),
retries: 0,
},
];
localStorage.setItem('offline-queue', JSON.stringify(updated));
return updated;
});
}
async function processQueue() {
for (const item of queue) {
try {
await fetch('/api/kanban/sync', {
method: 'POST',
body: JSON.stringify(item.action),
});
setQueue((prev) => {
const updated = prev.filter((q) => q.id !== item.id);
localStorage.setItem('offline-queue', JSON.stringify(updated));
return updated;
});
} catch (error) {
break;
}
}
}
return { queue, enqueue, processQueue };
}
The queue is now persisted, so if the user closes the browser mid-offline session, the queue is restored and synced when they return.
Using Service Workers for Background Sync
For a more robust offline experience, use a Service Worker with Background Sync to periodically attempt to sync when online:
// service-worker.js
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-kanban-queue') {
event.waitUntil(syncQueue());
}
});
async function syncQueue() {
const queue = JSON.parse(
await caches.match('offline-queue') || '[]'
);
for (const item of queue) {
try {
await fetch('/api/kanban/sync', {
method: 'POST',
body: JSON.stringify(item.action),
});
// Remove from queue on success
} catch (err) {
console.error('Sync failed:', err);
}
}
}
Register the Service Worker and trigger background sync:
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
// Trigger sync when online
if (navigator.onLine) {
registration.sync.register('sync-kanban-queue');
}
});
}
Background Sync ensures the queue is processed even if the app is closed, as long as the Service Worker is active.
Key Takeaways
- Detect connectivity: Use
navigator.onLineandonline/offlineevents. - Queue offline changes: Store actions locally; send when online.
- Optimize for UX: Update local state immediately for responsiveness.
- Handle conflicts: Compare versions and merge or pick last-write-wins.
- Persist the queue: Save to localStorage to survive browser restarts.
- Background sync: Use Service Workers for reliable async syncing.
Frequently Asked Questions
What if the user makes conflicting changes and then goes offline?
Queue both changes. When online, attempt to sync both. The server's conflict-resolution logic (or a merge function) decides the outcome. Ideally, inform the user of conflicts so they can manually resolve them.
How do I prevent duplicate syncs of the same action?
Assign each queued action a unique ID (timestamp + random). When the server confirms receipt, check the ID to avoid re-processing. Alternatively, use idempotent API endpoints (POST to /api/kanban/tasks/123 with version check).
Should I queue reads (fetches) or only writes (moves)?
Queue only writes. Reads are not critical for offline work (the user sees their local cache). Once online, fetch fresh data from the server.
How long should I retry failed sync attempts?
Reasonable window: retry for 24 hours, then discard. For user-critical data, increase to 72 hours or give the user an option to retry manually.
Can I show which tasks are pending sync?
Yes. Mark tasks with a pending icon or animation, showing their sync status. Use the queue state to determine which tasks have unsynced changes.