Offline Support in Web Apps: Foreground Queue for Offline Mutations — Part 5

This is the eighth post in my series about offline support in web applications and the fifth focused on the foreground queue. In the previous article, we covered error handling and retry strategies. At this point, the queue implementation itself is complete.
In this post, I want to show how the queue is actually used inside a React application — how we safely store offline mutations and sync them when connectivity returns. In other words, we’re moving from queue implementation to application integration.
Accessing the Queue from React
In the third article in this series, we integrated the queue with React using a small context factory. The goal was simple: ensure there is exactly one queue instance per persisted queue, while still making it easily accessible throughout the component tree.
The factory creates three things — a QueueProvider, a useQueue hook and a configured queue instance.
Here’s the configuration snippet from that article as a quick reminder:
const { QueueProvider, useQueue } = createQueueContext(() => ({
name: 'todo-mutations',
storageKey: 'todo-mutation-queue-v1',
identityKey: (item) => item.id,
processor: async (item) => api.post('/todos', item),
}));
The important part is that the queue instance is created once and shared. Every component interacting with the queue talks to the same object, which prevents race conditions and keeps the persisted state consistent.
In practice, integration is straightforward. We wrap the application (or a feature subtree) with the provider:
export default function App() {
return (
<QueueProvider>
<AppContent />
</QueueProvider>
)
}
From this point on, any component can call useQueue() and interact with the queue directly.
With the queue accessible throughout the application, we can now focus on the part that actually matters: using it to capture offline mutations. Let’s start with the most common scenario: executing mutations conditionally depending on the network state.
Capturing Offline Mutations
We need the queue for our mutation logic. In this example, we’re updating the status of a todo item. The key idea is simple:
Offline: store the intent in the queue.
Online: execute the request immediately.
The key idea is simple: mutations should behave the same from the UI perspective, regardless of network state. The mutation itself decides whether to execute immediately or store the action in the queue.
export const useTodoStatusUpdate = (id: string) => {
const queue = useQueue();
const isOnline = useNetworkStatus();
return useMutation({
networkMode: 'always',
mutationFn: async (status: string) => {
if (!isOnline) {
await queue.enqueue({ id, status });
return { queued: true };
}
await api.post(`/todos/${id}`, { status });
return { queued: false };
},
});
};
When the user is offline, we store the user’s intent in the queue. This means the action is not lost. The application can safely retry it later. When the user is online, we call the API immediately and complete the mutation normally.
This approach keeps the mutation API consistent while transparently supporting offline scenarios. With this pattern, the UI doesn’t need to know about the queue directly. It simply calls the mutation, and the mutation decides whether to enqueue or execute.
Interlude: Knowing if the User Is Online
In the code we looked at, we mentioned that we can check whether the user is online. Detecting whether the user is online sounds trivial, but it turns out to be surprisingly nuanced.
Browsers expose a property called navigator.onLine. Based on the MDN documentation:
The
onLineproperty of theNavigatorinterface returns whether the device is connected to a network.
if (!navigator.onLine) {
// Definitely no network
}
At first glance this looks like exactly what we need. Unfortunately, it's not always reliable enough for application logic.
The property returns a boolean: true when the browser detects the device has some network connection, false when the browser detects there is no connection. Browsers typically determine this using OS-level heuristics such as whether a network interface is active or whether the device is connected to Wi-Fi or Ethernet.
There are several practical issues with this approach.
It does not guarantee Internet access. A device might be connected to a Wi-Fi network that has no Internet connectivity. In that situation,
navigator.onLinestill returnstrue.Browser and OS differences. Some browsers treat any local network connection as "online", even if external connectivity is unavailable. Others behave differently.
Delays and false positives. The
online/offlineevents can lag behind real connectivity changes. Short network interruptions may also go undetected.
In practice, your best bet is to use a two-step approach — use navigator.onLine as a quick heuristic and verify connectivity with a real network request.
fetch('/health-check', { method: 'HEAD' })
.then(() => {
// Internet reachable
})
.catch(() => {
// Still offline or server unreachable
});
This ensures that the browser is connected to a network, that network has access to the internet, and it can actually reach your backend. In our example hook (useNetworkStatus), this logic can be encapsulated so the rest of the application doesn't need to worry about the details.
Showing Pending Work
When users work offline, actions are queued but not immediately executed. This means the UI should communicate that there is pending work waiting to sync. Our approach is to subscribe to the queue state and check whether the current item appears in the queue.
Here is a simplified example component.
type TodoCardProps = {
id: string;
title: string;
description?: string;
}
type TodoQueueState = {
isPending: boolean;
isProcessing: boolean;
}
export function TodoCard({ id, title, description }: TodoCardProps) {
const queue = useQueue();
const [state, setState] = useState<TodoQueueState>({
isPending: false,
isProcessing: false,
});
useEffect(() => {
const unsubscribe = queue.subscribe((s) => {
setState({
isProcessing: s.isProcessing,
isPending: s.items.some((item) => item.id === id),
});
});
return unsubscribe;
}, [queue, id]);
return (
<Card>
<CardTitle>
{title}
{state.isPending && !state.isProcessing && (
<Badge>
Pending sync
</Badge>
)}
{state.isProcessing && (
<Spinner size="sm" />
)}
</CardTitle>
<CardDescription>{description}</CardDescription>
</Card>
);
}
When the queue state changes, we update the UI state to know if the queue is processing or if this item is in the queue.
From there, the UI can show helpful indicators such as a “pending sync” badge and a spinner while processing. The exact UI depends on the application, but the important part is that users can see that their action was captured, even if it hasn’t reached the server yet.
In my experience, this greatly improves user trust when working offline.
Running the Sync
Finally, we need to decide when the queued work should be processed.
In this series, I intentionally keep the sync model simple: with the foreground queue model, queue runs only when the user transitions from offline to online. This keeps the implementation predictable and avoids overlapping sync processes.
Here's a simplified component example that handles this automatically.
export function QueueAutoSync() {
const queue = useQueue();
const isOnline = useNetworkStatus();
useEffect(() => {
// Run when status changes to online...
if (isOnline) {
queue.sync()
}
}, [isOnline, queue]);
return null;
}
This component doesn't render anything. Its only responsibility is to observe the network status and trigger a sync when connectivity is restored.
One small caveat here: depending on your application, you may also want additional triggers such as manual “Sync now” actions, sync pausing when the user goes back offline, periodic background sync or syncing on app focus. Starting with “sync when connection is restored” is often a good default.
Conclusion
That's it for today! In this post, we moved from the queue implementation to how it integrates with a React application.
Here's what we've covered:
expose and consume the queue through React Context
enqueue mutations when offline and execute them immediately when online
show pending work in the UI using state listeners
run a sync when connectivity returns
The queue captures user intent immediately and executes it when the network allows, making offline interactions reliable without complicating the UI.
Now that we know how mutations behave offline, the next step is improving the read side of the application — starting with data prefetching strategies.
If you enjoyed the article or have a question, feel free to reach out on Bluesky! 👋
Further Reading and References
- Photo by John Price on Unsplash





