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

This is the sixth post in the series about offline support in web applications and the third one focused specifically on the foreground queue. In the previous article, we introduced the concept of a foreground queue, discussed the characteristics we want to achieve, and covered the key parts of the implementation that help us reach those goals.
Today, we’re switching gears a little and moving closer to the user interface layer. We’ll cover how to expose queue state, how UI components subscribe to it, and how to wire everything into React without turning the queue into yet another state store.
Subscribing to queue state
Let’s start with exposing the queue state. I want React components to react to changes in a way that doesn’t involve sharing all of the queue state with the whole React component tree.
State as a derived view
The source of truth for the queue is the list of items stored in IndexedDB. In practice, the UI usually needs a bit more context than just “what’s in the database”, so I expose a small, read-only snapshot. I treat this snapshot as a derived view — it’s mainly for display and indicators, not for driving business logic.
export interface QueueState<T> {
items: Array<QueueItem<T>>;
isProcessing: boolean;
isPaused: boolean;
}
A few important constraints:
The queue itself is the only place that reads and writes this state.
I don’t need full, reactive synchronisation like you’d expect from a state management library.
IndexedDB is async, so any snapshot can technically be stale.
That last point is worth calling out. Because all reads go through async APIs, there’s always a chance the state you’re looking at is slightly out of date. For this use case—showing indicators or counts in the UI—that trade-off is most likely acceptable. Just keep it in mind and avoid relying on this state for critical logic.
A simple subscription pattern
I decided to go with a simple subscription pattern for publishing the queue state updates. Instead of pushing state continuously, I let the queue decide when listeners should be notified.
From the consumer’s point of view, it looks like so:
queue.subscribe((state) => {
console.log(state.items.length);
});
The UI subscribes, reacts to updates, and doesn’t worry about how or when the state is produced.
Implementing subscriptions in the queue
Under the hood, this is implemented with a lightweight listener registry.
export type QueueListener<T> = (state: QueueState<T>) => void;
export class Queue<T> {
private listeners = new Set<QueueListener<T>>();
subscribe(listener: QueueListener<T>) {
// Store `listener` function...
this.listeners.add(listener);
this.notifyListener(listener);
// Allow to unsubscribe...
return () => {
this.listeners.delete(listener);
};
}
private async notifyAllListeners() {
this.listeners.forEach((listener) => this.notifyListener(listener));
}
private async notifyListener(listener: QueueListener<T>) {
const state = await this.getState();
// Notify a single listener with the current state
listener(state);
}
private async getState() {
const items = await this.read();
return {
items: [...items],
isProcessing: this.isProcessing,
isPaused: this.isPaused,
};
}
}
A few things are worth highlighting here:
subscribeis a public method that registers a listener and returns an unsubscribe function.New listeners are notified immediately with the current state.
Listeners are only notified when the queue explicitly calls
notifyAllListeners.
That last point is intentional. Whenever the queue processes an item, pauses, resumes, or mutates storage, I manually trigger notifications, calling notifyAllListeners. You need to be aware that with this pattern, state updates won't be sent out automatically.
Additionally, because getState() is async, rapid successive notifications may resolve out of order. In my case, this hasn’t been an issue, but it’s something to be aware of if you extend this pattern.
This pattern makes it straightforward to build small UI features like showing “3 actions pending” or “queue is currently syncing”. In my experience, this level of state awareness strikes a good balance. It's simple and clear, yet it provides a good separation between the queue's internals and the UI.
React integration via context
Now we have everything we need to connect the queue to the UI layer. As mentioned in previous articles, my example will focus on React, but the same or similar principles can be applied to any other framework.
First of all, the queue is stateful, long-lived, and tied to a single IndexedDB key. Creating multiple instances is a fast path to race conditions and corrupted state. To eliminate this, the safest approach is to treat the queue as a singleton per feature and make it available once, for example through React context.
One queue, one configuration
In practice, it translates to the following requirement: there must be exactly one queue instance for any one persisted queue. That means:
the configuration is defined once
the queue instance is created once
every component talks to the same object
This doesn’t mean there can only ever be one queue in the app—only that there should be one queue per storage key and feature. The solution I chose was a small factory that creates a context, along with a provider component and a hook for accessing a specific queue.
const { QueueProvider, useQueue } = createQueueContext(() => ({
name: 'todo-mutations',
storageKey: 'todo-mutation-queue-v1',
identityKey: (item) => item.id,
processor: async (item) => api.post(`/todos/${item.id}`, item.status),
}));
This keeps feature code clean. I define the config once, and from that point on I just use QueueProvider and useQueue. With this setup, integration is straightforward. I wrap the application—or just a feature subtree—with the provider.
Anywhere below that, I can call useQueue() and interact with the queue directly:
enqueue new items
start sync processing
subscribe to state changes
There’s no extra indirection or proxy state. Components talk to the queue object itself.
The context factory
Here’s the factory implementation that makes this work.
export function createQueueContext<T>(configFactory: () => QueueConfig<T>) {
// Define a context for the queue...
const QueueContext = createContext<Queue<T> | null>(null);
type QueueProviderProps = {
children: ReactNode;
}
// Define a provider for the queue
function QueueProvider({ children }: QueueProviderProps) {
// Create the config using the factory function...
const config = configFactory();
// Create a ref to store the queue...
const queueRef = useRef<Queue<T> | null>(null);
if (!queueRef.current) {
// Only create the queue once, so the reference to the object is stable....
queueRef.current = new Queue(config);
}
return (
<QueueContext.Provider value={queueRef.current}>
{children}
</QueueContext.Provider>
);
}
// Define a hook to access the queue...
function useQueue() {
const ctx = useContext(QueueContext);
if (!ctx) {
throw new Error('useQueue must be used within QueueProvider');
}
return ctx;
}
return { QueueProvider, QueueContext, useQueue };
}
A few details here are worth calling out.
Factory owns the configuration. That makes the queue explicit and self-contained, instead of relying on global setup or hidden dependencies.
Queue instance is created exactly once. Using a ref ensures that React re-renders won’t recreate it.
Provider only renders once. It always provides a stable reference to the same queue object, even if the state inside of the queue changes. In this case, using context is perfectly fine because we’re not pushing changing values through it. From React’s perspective, the context value is stable; all the change happens inside the queue.
The hook gives direct access to the queue. From React’s point of view, the queue is just an external object. Components can call
queue.enqueue()orqueue.subscribe()without coupling UI renders to internal queue state.
Summary
That’s it! We covered the UI-facing side of a foreground queue and how it fits into a React codebase.
The queue remains the single source of truth, backed by IndexedDB.
The UI consumes a small, read-only snapshot of derived state.
State updates are delivered through an explicit subscription mechanism.
React context is used purely to share a single, long-lived queue instance.
Components interact directly with the queue object, keeping concerns clearly separated.
This setup has worked well for me in practice. It’s simple, explicit, and avoids accidental complexity while still enabling useful UI feedback like pending counts or sync indicators.
In the next article, I’ll build the final piece that the queue implementation is still missing, diving into error handling and retry strategies—arguably the most subtle parts of making offline queues reliable.
If you enjoyed the article or have a question, feel free to reach out on Bluesky! 👋
Further reading and references
- Photo by Sorina Bindea on Unsplash





