Local storage is not offline

X8B6 could retain a form in the browser and still lose the meaning of an unconfirmed inventory change, forcing offline work to become a protocol.

X8B6's first offline feature saved the current form to localStorage. If the browser lost its connection, a person could close the tab, reopen it, and find the quantity they had typed.

I called that offline support.

Then I tested two stock changes, a timeout during submission, and a reconnect to a server that had accepted one change but not acknowledged it. The form still held a number. The application no longer knew what that number meant.

Was it the last confirmed quantity? A desired replacement? A change of minus two? Had the server already applied it? Saving interface state had preserved pixels and discarded intent.

Offline work began when I stopped asking how to cache the form and started asking how to represent an action before its outcome was known.

A saved record hid the action

The ledger displayed an inventory item with quantity 12. A person counted ten and typed 10. The easiest local model overwrote the cached record:

localStorage.setItem('item:lamp', JSON.stringify({ quantity: 10 }))

That value could support several different intentions:

  • Set the authoritative quantity to ten.
  • Record that two units were removed.
  • Correct an earlier mistake.
  • Store a draft count that had not been submitted.

The server might meanwhile change from 12 to 11 because of another accepted action. Replaying “quantity equals 10” and replaying “remove 2” would produce different outcomes. The cached record did not say which one the person meant.

I had treated the record as the unit of work because the screen looked like an editable record. The actual unit was an operation with an author, subject, stable identity, and creation time.

The local store needed to preserve that operation until the server could return a durable result.

Durable intent came before optimistic state

I introduced a local operation envelope:

type InventoryOperation = {
  operationId: string
  itemId: string
  kind: 'adjust'
  delta: number
  createdAt: string
  baseRevision: number | null
  status: 'pending' | 'sending' | 'confirmed' | 'needs-review'
}

The operation delta: -2 survived independently of the view that created it. The screen could project the last confirmed quantity plus pending local adjustments and label the result as provisional.

The write order mattered. X8B6 first committed the operation to durable browser storage, then updated the optimistic projection, then attempted the network request. If the tab closed after the first step, the work still existed. If the network response arrived, the receipt could be associated with the stable identifier.

This felt slower than immediately changing a JavaScript object. In practice, the local commit completed quickly enough, and the interface could say “Saved on this device” before saying “Confirmed.” Those were different milestones.

Offline support was no longer a network fallback. It was an execution order that protected intent before taking a dependency on the network.

localStorage was attractive because it was synchronous and simple. That simplicity became a problem as soon as the project needed multiple operations, queryable states, and atomic updates.

One JSON array stored under a single key meant every change read, parsed, modified, serialized, and rewrote the entire outbox. Two tabs could read the same array and overwrite each other's additions. A large value blocked the main thread during interaction. Corruption or a partial schema change affected the whole collection.

The API also offered no transaction tying an operation insert to a local projection update. I could invent locking conventions, but I would be building a fragile database protocol around a string map.

I kept localStorage for tiny preferences whose loss did not endanger work. The outbox moved toward IndexedDB, where operations could be individual records and updates could occur inside transactions.

That migration did not make the application offline by itself. IndexedDB was a more appropriate storage mechanism. The offline behavior still came from operation identity, receipts, reconciliation, and visible states.

Changing storage without changing the model would have produced a more durable ambiguous record.

Optimistic did not mean confirmed

The first interface updated the quantity immediately and displayed it exactly like a server value. The speed felt good and made a pending operation invisible.

I kept the optimistic projection but separated its claim:

Confirmed quantity: 12
Pending on this device: −2
Projected quantity: 10

The ordinary view condensed those lines into one value with a small “pending sync” explanation. Opening detail showed the operation and last confirmed base. If the server accepted it, the confirmed projection advanced and the pending marker disappeared. If it rejected or conflicted, the interface retained the operation and explained what needed review.

I avoided a permanent cloud icon whose states required a legend. The language named ownership: saved on this device, sending, confirmed by server, or needs review.

The person did not have to wait for confirmation before continuing with another item. Each new operation joined the outbox. The interface remained honest that the resulting quantities were local projections, not yet shared truth.

Optimism became a presentation of durable intent, not a visual claim that the network had already succeeded.

A timeout created outcome unknown

The most important state appeared when the request reached the server but the response did not reach the browser.

The first client marked the operation failed after a timeout. Retrying sent the same adjustment again. If the server had committed the first request, inventory changed twice.

The timeout did not prove failure. It proved that the client did not know the outcome.

Every operation carried a stable identifier generated before local storage. The server stored that identifier with its result. Submitting the same identifier again returned the existing receipt instead of applying the adjustment twice.

operation: op_7c2...
first request: committed, response lost
retry: same operation identity
server response: existing receipt, quantity revision 84

The local state after timeout remained pending or outcome-unknown, not failed in the sense of safe to repeat as new work. Automatic retry reused the same identity. A person could inspect the operation without deciding whether the first attempt “probably” worked.

This was the boundary where offline design met idempotency. Durable local storage protected the request; a durable server receipt protected its replay.

My first reconnect loop read every local record and sent the latest version to the server. That assumed the server had waited unchanged and that order did not matter.

X8B6 instead reconciled operations. It loaded pending actions in their local creation order, checked for existing receipts, submitted eligible operations, and incorporated the server's confirmed projection after each result.

The client did not blindly keep sending after a conflict. An adjustment might depend on an item that had since been archived or on a base revision whose meaning changed. The queue paused that item's dependent operations and allowed unrelated items to continue.

This required scope. A conflict on one lamp should not block an adjustment to a chair. The outbox grouped work by item identity while preserving a stable overall order for display.

Reconnect began with evidence gathering: current server projection, known receipts, and outstanding local operations. Only then could the client decide what was safe to transmit or rebase.

Calling the process “sync” had made it sound like copying files. It was a conversation between confirmed state and durable user intent.

Two tabs were two writers

The cached-form design assumed one browser tab. Opening X8B6 twice created two independent in-memory views over shared browser storage.

With localStorage, a last writer could replace the whole outbox array. With IndexedDB, individual inserts were safer, but two tabs could still attempt to send the same pending operation. Stable operation identity prevented duplicate server effects, though the user experience could remain confusing.

I added a simple sender lease in browser storage. One tab claimed responsibility for draining the outbox for a short period and renewed while active. Another tab could add operations and display them but did not run a competing send loop unless the lease expired.

The lease was not a perfect distributed lock. Tabs could pause, clocks could drift, and a lease could briefly overlap. Correctness still depended on server idempotency. The lease reduced redundant work and noisy UI transitions; it did not carry the safety guarantee.

Cross-tab notifications prompted each view to refresh its projection when operations or receipts changed. A tab never assumed its in-memory array was the whole local truth.

Supporting multiple tabs exposed a general rule: browser storage is shared durable state, not a private extension of one page's variables.

Writing to browser storage could fail because of quota, privacy settings, corruption, or unavailable APIs. The first offline design treated the local write as guaranteed and immediately cleared the form.

The revised flow waited for the durable local commit. If storage failed, the typed adjustment remained visible and the interface said it had not been saved on the device. It did not attempt a network submission that could succeed while leaving no local identity for its receipt.

That ordering was conservative. When online, it would have been possible to send directly after a storage failure. I rejected that fallback because the client could again enter outcome-unknown without a durable operation to reconcile after reload.

The interface offered a copyable summary of unsaved work and a retry after freeing space. It did not call the app offline-ready merely because the normal storage path worked in my browser.

I added a startup check that opened the database and performed a small transaction before showing offline capability. Feature detection alone could say an API existed without proving it was usable in the current context.

Offline confidence had to include the local disk boundary.

Clearing site data was not a sync strategy

During development, deleting browser storage fixed stuck migrations and inconsistent projections. That habit was equivalent to throwing away the only copy of pending work.

The app distinguished derived local projections from authoritative local operations. A broken projection could be rebuilt from the last server snapshot plus the outbox. Pending operations could not be casually cleared.

A reset tool listed what it would remove:

rebuildable cache: safe to replace
confirmed receipts: recoverable from server within retention window
pending operations: only copy on this device
draft input: only copy on this device

The destructive action required pending work to be exported, confirmed elsewhere, or explicitly discarded. In ordinary operation, migrations handled schema changes without asking a person to diagnose storage internals.

This taxonomy also guided retention. Old confirmed projections could be evicted under pressure. Unconfirmed intent received priority. Treating every local record as “cache” would have made the browser free to discard the most important data first.

The word cache describes recoverability, not merely location.

Offline state appeared per operation

A global “offline” banner could explain the network condition and could not tell somebody whether their last three adjustments were safe.

Each operation moved through visible milestones:

draft
saved on this device
waiting to send
sending
confirmed
needs review

The overall page summarized counts: three waiting, one needs review. Opening the outbox showed item, adjustment, local time, last attempt, and server result if any.

The state did not depend only on navigator.onLine. That signal could be true while the service was unreachable and false while the local database remained perfectly usable. Network events could trigger attempts; request outcomes and receipts determined operation state.

I kept confirmed operations visible briefly, then folded them into item history. Immediate disappearance made it hard to know whether a sync succeeded. Permanent display made the outbox an archive. The transition acknowledged completion without allowing old work to dominate.

Offline UX became a set of accountable work states rather than a disconnected badge.

Suppose the server quantity was 12 when the device went offline. One local operation recorded -2. Another device later confirmed -1, making the shared quantity 11. When the first device returned, applying its delta could produce 9 and preserve both removals. Replacing the quantity with 10 would erase the other operation.

That made adjust a useful domain operation. It was not safe in every case. A recount that explicitly established an absolute physical quantity had different conflict semantics from a removal. I added a separate recount kind rather than overloading one field.

The server could apply commutative adjustments according to policy and flag a recount when its base revision was no longer current. The review screen showed the confirmed quantity, the local observation, and intervening adjustments. It did not reduce the situation to “version conflict.”

This was where preserving intent paid off. The system could reason differently about “remove two” and “I counted ten” because the operations retained those meanings.

No generic merge algorithm could invent the distinction after both had been stored as { quantity: 10 }.

Offline capability meant keeping more information on a device for longer. X8B6's invented inventory did not include sensitive customer data, but author names, notes, and operational history still deserved limits.

The outbox stored only what was required to replay an operation. It did not copy entire item records into every entry. Confirmed data followed a bounded local retention policy, and signing out removed recoverable projections after warning about any unconfirmed work.

Browser storage was not encrypted merely because it belonged to an application origin. Anyone with access to the device profile could potentially inspect it. The project documented that limit rather than presenting local persistence as a secure vault.

I avoided storing reusable credentials inside the operation records. Authentication state and durable intent had different lifecycles. An operation could remain pending until the user authenticated again, then be submitted under explicit identity.

Offline-first does not mean store everything. It means decide which work must survive, where it survives, and which risks that creates.

Once operations were durable, opening the application was no longer “load cached data and render.” Startup reconciled several sources:

  1. Open and migrate the local database.
  2. Load pending operations and confirmed receipts.
  3. Rebuild a local projection from the last snapshot plus pending intent.
  4. Render an honest local state.
  5. If reachable, fetch server changes and existing receipts.
  6. Reconcile before draining eligible work.

The page could become useful after step four without waiting for the network. It clearly labeled when the server snapshot had last been confirmed. A spinner over an empty screen would have discarded the value of local persistence.

If migration failed, startup did not silently create a new empty database. It preserved the old store and offered recovery detail. The local data might contain the only copy of work.

Treating every launch as recovery kept initialization from assuming a clean state. The normal case remained fast; the exceptional cases had somewhere to go.

Local persistence was one layer of the promise

Saving a form value protected a draft from one kind of interruption. It did not say what the person intended, whether the server had applied it, how to retry safely, or how concurrent changes should combine.

X8B6 became meaningfully offline when it preserved operations before network attempts, projected them without calling them confirmed, paired them with server receipts, and reconciled them against a changing shared state. IndexedDB gave that model a better local home. It did not supply the model.

The project also became more honest about failure. Storage could be unavailable. Multiple tabs could compete. Timeouts could leave outcomes unknown. Conflicts could require domain judgment. A useful interface named those states per operation and protected unconfirmed work from cleanup.

I still use local persistence for drafts, and sometimes that is all a feature needs. I no longer call the presence of a browser key “offline support” without asking what happens after the value leaves the form.

Offline is not where the record sits. It is whether a person's intent can survive interruption and reach a knowable outcome without being duplicated, erased, or mislabeled along the way.