DEV Community

Anthony KOZAK
Anthony KOZAK

Posted on Originally published at exoa.dev

How to Build Offline-Tolerant Web Apps That Recover Cleanly

Reliable web applications are not defined by what happens on a perfect connection. They are defined by what happens when Wi-Fi disappears during a save, a mobile browser suspends a tab, or a request succeeds on the server but its response never reaches the client. After 16 years in game development, from shipping Eagle Flight Arcade at Ubisoft Montreal in 2016 to maintaining Touch Camera PRO and delivering freelance projects, I approach those failures as normal operating conditions, not rare edge cases.

Key Takeaways
  • Offline-tolerant does not mean every feature must work without a server.
  • Local state, pending mutations, and confirmed server state must remain distinct.
  • Retries are safe only when operations are idempotent.
  • Conflict resolution is a product decision, not merely a database decision.
  • Synchronization state should be visible and understandable to users.

What does offline-tolerant web development actually mean?

A disconnected application is not automatically an offline application. Caching HTML, JavaScript, CSS, and images may let the interface open, but that says nothing about whether a form submission is durable or whether an edit will collide with newer server data. I use offline-tolerant to describe software that can survive temporary network failure without losing work, duplicating operations, or lying about what has reached the server.

That distinction comes naturally to game developers. When I worked as a Gameplay Programmer on Eagle Flight Arcade at Ubisoft Montreal in 2016, the product shipped across PSVR, Oculus Rift, and HTC Vive. Real-time software forces you to think in explicit states. Input has been sampled or it has not. A transition has completed or it has not. Web applications need the same discipline. A button becoming disabled after a click is not proof that an operation was committed remotely.

I define three practical capability levels before implementation. First, the application can load previously cached screens. Second, users can read locally retained business data. Third, users can create or edit data and synchronize those changes later. Each level adds meaningful complexity. Many internal tools only need the first two, while field applications may require all three. The mistake is calling the entire product offline-ready without saying which actions remain valid. In 2026, browsers provide mature primitives for storage and caching, but product semantics still belong to the team. Write a capability table for every important action: available offline, locally durable, server-confirmed, conflict-prone, or server-only. That table is more useful than a vague promise that the app works anywhere.

Which state should live locally, remotely, or in transit?

I separate application state into three buckets: confirmed server state, optimistic local state, and pending mutations. Confirmed state is the latest version the server has acknowledged. Optimistic state is what the interface shows immediately after a user acts. Pending mutations are the commands that still need server confirmation. Mixing these concepts into one object is the source of many synchronization bugs because the interface can no longer explain where a value came from.

Durable browser data belongs in IndexedDB, not only in component state or localStorage. Component state disappears when the page closes. localStorage is synchronous, string-oriented, and poorly suited to larger structured collections. IndexedDB supports transactions, indexes, and asynchronous access. I still place a repository layer around it so the rest of the application does not know whether data came from React state, IndexedDB, or an HTTP response. That boundary also gives me one place for schema migrations.

Not everything should be retained. Authorization decisions, financial calculations, limited inventory, and other server-owned invariants must be validated remotely. Authentication tokens also deserve deliberate handling rather than being dropped into arbitrary browser storage. Local data should be the minimum needed to preserve the workflow. Give every stored record a stable identifier, a schema version, a server version if available, and timestamps used for diagnostics rather than blind conflict resolution. I also keep transient UI details, such as an open menu, outside the durable domain model. The goal is not to clone the entire server database into a browser. The goal is to preserve enough intent that a refresh, suspended tab, or brief outage cannot erase meaningful user work.

How should a durable mutation queue be designed?

A reliable queue stores user intent before attempting the network request. If someone edits a record, I first create a local operation, commit it to IndexedDB, and then update the interface optimistically. Only after that local transaction succeeds do I send the operation. This order protects against a tab closing between the click and the write. It also means the visible optimistic state can always be reconstructed by replaying pending operations over the last confirmed snapshot.

Each queued mutation needs more than a URL and JSON body. I include a unique operation ID, operation type, target resource ID, creation time, payload schema version, expected server version, attempt count, and current queue state. Useful states include pending, sending, blocked by conflict, rejected, and confirmed. A network timeout returns an item to pending. A validation error marks it rejected and preserves enough context for the interface to explain what happened. A version conflict blocks the item until the resolution policy runs.

Ordering must be defined per resource. Two edits to the same document usually need sequential processing, while edits to unrelated records may run concurrently. Deletes require particular care because a later queued edit may target something that no longer exists. I prefer semantic commands such as rename-project or set-delivery-address over arbitrary object replacement. Semantic commands are easier to validate, migrate, merge, and audit. Queue compaction can combine repeated safe edits, but it should never silently combine operations with external consequences. A queue is not just a retry list. It is a durable record of unconfirmed intent, and its data model deserves the same attention as a server-side database table.

Why must retries and idempotency be designed together?

Timeouts create ambiguity. The server may have completed an operation even though the client never received the response. Retrying a non-idempotent request can then create a duplicate order, message, reservation, or audit entry. The fix is not a longer timeout. Every logical mutation should receive a unique idempotency key before its first attempt, and every retry of that mutation must reuse the same key.

The server records the key, request identity, status, and result in the same reliable boundary as the business operation whenever possible. A duplicate request can then return the previous result. Keys should be scoped to the authenticated actor and operation so one user cannot probe another user's results. Retention time must also exceed the client's realistic retry window.

using System;
using System.Collections;
using System.Text;
using UnityEngine.Networking;

[Serializable]
public sealed class PendingMutation
{
public string operationId;
public string resourceId;
public long baseVersion;
public string payloadJson;
}

public static class MutationSender
{
public static IEnumerator Send(
string endpoint,
PendingMutation item,
Action<long, string> completed)
{
byte[] body = Encoding.UTF8.GetBytes(item.payloadJson);

    using (var request = new UnityWebRequest(endpoint, "POST"))
    {
        request.uploadHandler = new UploadHandlerRaw(body);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");
        request.SetRequestHeader("Idempotency-Key", item.operationId);
        request.SetRequestHeader("If-Match", item.baseVersion.ToString());

        yield return request.SendWebRequest();
        completed(request.responseCode, request.downloadHandler.text);
    }
}

}

This Unity-flavored example is relevant when a web backend also serves a game or interactive client. The important point is not the HTTP library. The operation ID survives retries, while If-Match communicates the version the user edited. The client should remove the queue item only after an explicit server confirmation. It should treat a timeout or dropped connection as unknown, not failed. HTTP methods such as PUT have defined idempotent semantics, but business APIs still need careful implementation. A POST endpoint can also be safely retried when an application-level key guarantees one logical effect.

How should synchronization conflicts be resolved?

Conflicts happen when local work was based on an older version than the server now holds. I prefer explicit version numbers or entity tags over comparing timestamps. Device clocks drift, timestamps can have inconsistent precision, and last-write-wins may silently erase useful work. When the client sends a base version, the server can accept the operation, reject it with a conflict, or merge it according to a documented domain rule.

There are several valid strategies. Independent fields can often merge automatically. Appending a comment may be safe even if the parent record changed. Replacing a shared document usually requires more caution. Counters and sets may support commutative operations, while deleting a record should normally take precedence over a stale cosmetic edit. For content that users can understand, a conflict screen can show the local value, remote value, and a deliberate choice. This is less magical, but it avoids invisible data loss.

The resolution policy belongs to product design because it depends on consequences. A rejected profile color change is mildly annoying. A duplicated purchase or overwritten legal approval is not. I ask teams to classify operations as mergeable, replaceable, append-only, destructive, or server-exclusive. Then we define the policy before writing synchronization code. The server remains authoritative for permissions and invariants even when the client has a polished offline mode. After resolution, create a new mutation against the current server version instead of rewriting history in place. That produces a clearer audit trail and keeps the queue deterministic. Good conflict handling is not about eliminating disagreement. It is about making disagreement visible, preserving both sides long enough to decide, and ensuring the same inputs produce the same resolution.

How can the interface communicate synchronization honestly?

The interface should distinguish saved locally from synchronized remotely. A generic green checkmark often hides too much. I use a small vocabulary such as saving locally, pending sync, synchronized, needs attention, and rejected. The wording should match the consequence of the action. For a draft note, pending sync may be enough. For a booking or payment, the interface must clearly state that the server has not confirmed the operation.

Connectivity indicators are useful, but browser online events are only hints. A device can be connected to a router while the API, DNS, authentication provider, or upstream dependency remains unavailable. I derive operational status from real requests and queue progress rather than using navigator.onLine as truth. I also avoid aggressive toast notifications for every transition. Persistent badges near affected records and a central synchronization panel are usually easier to understand. Users should be able to inspect blocked items, retry recoverable failures, discard an unwanted local edit, or copy their unsynchronized work.

Optimistic UI also needs restraint. It is appropriate when rejection is uncommon and reversible. It is dangerous when users may act on an outcome that has not been confirmed. For example, changing a local label can appear immediately, while access grants should wait for server approval. Closing a tab with pending work may justify a warning, although browser restrictions mean it cannot be the only safeguard. Most importantly, never clear a form merely because a request was sent. Clear it after local durability is established, then communicate remote status separately. Touch Camera PRO taught me to value immediate, legible feedback in interaction design. Web synchronization deserves that same feedback loop. Users tolerate waiting better than they tolerate uncertainty or vanished work.

How should failure modes be tested before production?

The happy path proves very little. I test synchronization by interrupting it at specific boundaries: before the local write, after the local write but before the request, after the server commit but before the response, and during conflict resolution. Each boundary should have a defined recovery behavior. Browser developer tools can simulate offline mode and network throttling, but I also stop the API, force 500 responses, expire authentication, and return malformed or outdated payloads.

Build a matrix around operation type and failure type. Cover a refresh with queued work, two tabs editing the same record, a service worker update during use, storage quota exhaustion, an IndexedDB migration failure, and a user signing out while mutations remain. Test duplicate delivery by sending the same idempotency key several times. Test out-of-order responses even if the current transport usually preserves order. Automated browser tests can seed IndexedDB, intercept requests, and verify that pending work survives a reload. Server integration tests should prove that recording the idempotency key and applying the business change cannot drift apart.

Observability completes the test strategy. Log operation IDs on both client and server, but avoid placing sensitive payloads in logs. Track queue age, retry count, conflict frequency, rejected operation types, and the version of the client schema that created each mutation. Those signals make a vague report such as my change disappeared diagnosable. In 2025 and 2026, AI tools can generate a queue, service worker, or test scaffold quickly. They cannot infer every domain invariant or prove recovery behavior. I review generated code by forcing ambiguous outcomes and checking persisted state after each one. If the team cannot explain what happens when the response disappears after a successful commit, the feature is not ready.

What architecture would I choose for an offline-tolerant app in 2026?

I would start with a conventional server-authoritative application and add offline capability only to workflows that justify it. On the client, I would use a repository abstraction over IndexedDB, a durable semantic mutation queue, a deterministic projection that combines confirmed data with pending intent, and a synchronization worker with bounded exponential backoff and jitter. A service worker would cache the application shell and selected responses, but I would not assume it can run forever or complete every background task.

On the server, I would require authenticated idempotency keys for mutations, explicit resource versions, transactional business rules, and machine-readable validation errors. I would expose an operation status endpoint when commands can take significant time. Administrative tooling should let authorized support staff inspect an operation by ID without exposing unrelated user data. For teams that need this combination, my web application, API, and admin dashboard development work focuses on the complete operational path rather than stopping at a generated CRUD interface.

I would also define limits. Cached data expires. Some actions remain online-only. Queue size is bounded. Old mutation schemas need migrations or a clear recovery path. Sensitive information is minimized and removed on sign-out where appropriate. Client work for organizations ranging from Ticketly to Loreal Viva Tech 2024 has reinforced a broader lesson from games: software runs in environments you do not control. Touch Camera PRO must coexist with different projects and input assumptions, just as a web client must coexist with browser suspension, weak connectivity, and evolving APIs. The best architecture is not the one with the most offline features. It is the one that preserves intent, protects server invariants, communicates uncertainty, and recovers predictably when the network behaves like the unreliable dependency it is.

References & Further Reading

Top comments (0)