Offline Support in Web Apps: Foreground Queue vs. Background Sync

I help product teams build quality software and lead engineering efforts. Currently working at OpenSpace as a Senior Software Engineer.
Offline support in web applications has been on my mind a lot lately. I’m working on adding it to one of the projects I contribute to, and I quickly learned there's a lot of complexity to this topic. Deciding how to approach this can be challenging, especially if you want something that works reliably without jumping straight into PWAs and service-worker-powered background features.
This post kicks off a short series on building offline-capable applications. I want to start with two core architectural patterns that most often form the foundation of almost every offline strategy: Foreground Queue and Background Sync.
I’ll explore both approaches from the client perspective—your SPA, Electron shell, or mobile web wrapper. PWAs can come later as a progressive enhancement.
What Problem Are We Solving?
When the client loses connectivity, the user still wants to do things—create tasks, edit notes, send messages, update settings. At some point, those changes must reach the server. The question is how and when the sync happens, and what the user sees while it’s happening.
Most designs naturally fall into one of two models.
Foreground Queue — Explicit, Traceable, User-Visible
The Foreground Queue model focuses on showing current state, giving clarity and predictability to the user. The idea is simple: whenever the user performs an action that needs to reach the server, the app writes that operation into a durable queue (most often IndexedDB). Those operations sit there until the app decides it’s time to replay them. Foreground Queue is about recording intent and replaying it later.
This is an application-level queue, not a message broker.
And “decides” really means while the app is running. Nothing magical happens in the background. If the user closes the tab or the desktop client isn’t active, the queue stops moving. Sync only resumes when the user comes back.
In practice, this makes the logic easy to reason about. You detect that you’re online, you pull pending items out of the queue, and you try sending them one by one. If something fails, you mark it as failed, apply your backoff strategy, and try again later. FIFO ordering works well, but you can get fancy if your domain requires it.
Here’s a simplified sketch of the sync function:
async function sync() {
const items = await queue.loadItems();
for (const item of items) {
try {
await item.process();
await queue.markCompleted(item);
} catch {
await queue.markFailed(item);
break;
}
}
}
The UX that this approach brings is what I think of as visibility-first. Users know what’s pending because you show it to them—an Outbox, a little “pending” pill next to items, a count of unsent changes. You don’t pretend the server is up to date—the user understands others won’t yet see the change, and they get less surprised if a conflict appears after submission.
If you want straightforward reasoning and explicit status for a handful of clearly defined actions, and can accept relying on retries and making sure requests are idempotent, Foreground Queue is hard to beat.
Background Sync — Optimistic, Continuous, Seamless
Background Sync takes a different approach. Instead of queuing operations and waiting for a coordinated replay, the app immediately updates local state and behaves as though the server has already accepted the change. Background Sync is about maintaining a local copy of server state and continuously reconciling it.
Despite the name, this isn’t the browser’s Background Sync API. It’s an architectural pattern where the app continuously reconciles local and server state while it’s running.
This model can be incredibly pleasant to use. The whole app feels fast because the UI never waits on the network. Under the hood, though, you’re doing more work: you label objects as “dirty,” schedule periodic sync attempts, push changes whenever you detect connectivity, and then reconcile any differences between the client and server versions. A lot of things can go wrong here.
Because changes apply locally right away, you also have to be more thoughtful about conflict resolution. If the server disagrees with your optimistic edit, the user may see a correction or merge state later. Handling that gracefully makes or breaks the experience.
The core loop can look something like this:
async function backgroundSync() {
const dirty = await store.getDirtyEntities();
for (const entity of dirty) {
try {
const updated = await push(entity);
await store.applyServerState(updated);
} catch {
/* retry later */
}
}
}
In a browser-only environment, however, it’s worth remembering that this isn’t real background sync. Nothing runs after the tab closes in the browser. Electron and native shells can give you genuine background execution, which makes this model far more practical.
The trade-off is complexity in maintaining a client-side replica of the server state. That means concurrency issues, periodic synchronisation, retries, and merges. And because users assume everything succeeded instantly, any visible rollback is more noticeable and must be communicated carefully.
Practical Recommendations
Here are a few practical guidelines I’ve found helpful when choosing and implementing an offline strategy.
Use IndexedDB for persistent storage. With rich storage quota, ability to handle complex data (like blobs/files), asynchronous API and browser support, it’s probably the safest bet for client data storage in any approach.
Design for at-least-once delivery. Use stable operation IDs and idempotent endpoints. Assume every request can be sent twice, and design endpoints accordingly.
Pick UX deliberately. Users need clarity and control? Foreground Queue. Users expect seamless, fast, “everything just saves”? Background Sync. Decide upfront whether users should ever see ‘pending’ as a first-class state.
Don’t treat the architecture as a binary choice. Many real-world apps mix both models—for example, using a Foreground Queue for destructive or high-risk actions, and Background Sync for low-risk, high-frequency edits.
Be honest about your runtime. If your app can’t run code in the background, don’t promise background behavior. If the tab closing stops all work, reflect that in copy and product expectations.
Takeaway
To sum it up in one rule: Foreground Queue prioritises transparency; Background Sync prioritises smoothness. Both are solid approaches, and both can coexist in the same architecture. The right choice depends on your environment, your users, and how much complexity you’re willing to take on. The real decision isn’t only technical—it’s whether you want users to see uncertainty or hide it.
If you have thoughts or want to share your own offline challenges, feel free to reach out on Bluesky! 👋
Further reading and references
- Photo by Ray Hennessy on Unsplash






