The cache was the product

Building an offline field ledger revealed that dependable local work requires an operation log, reconciliation, and visible uncertainty.

X8B6 was supposed to be an inventory form that also worked offline. That sentence concealed almost the entire product.

The people using it moved equipment through basements, service corridors, and temporary storage rooms. Connectivity did not fail occasionally; it appeared briefly and without warning. The first prototype fetched a list, cached it locally, and queued form submissions when a request failed. In a demo, that looked like offline support.

In use, it created questions the interface could not answer. Had the save happened locally? Was it waiting to sync? Had somebody else changed the same item? Would pressing save again produce a duplicate? Which version should win when the network returned?

The cached list was not an accessory to the product. While offline, it was the product. That meant it needed an actual data model.

The original task sounded smaller

X8B6 began as a personal project to understand why web forms became unreliable the moment connectivity stopped being a background assumption. I used an invented equipment catalogue and treated a tablet moving through a building as the reference environment.

The first task path was deliberately ordinary: find an item, record that it moved, attach a short note, and hand the device to somebody else at the end of a shift. I expected the interesting work to be caching the catalogue and retrying a request.

The handoff changed the design. A person receiving the device needed to know whether the screen represented shared state, local work, or a mixture. “It will sync later” was not an adequate answer if later had already partly happened.

I wrote the project questions around that moment:

  • Which work exists only on this device?
  • Which work has a durable server result?
  • Which screen values include pending local intent?
  • Which items require a decision before they can synchronize?
  • What can be safely handed to another person or another device?

The product was no longer an inventory form with an offline mode. It was a ledger that had to explain custody of work across an unreliable boundary.

The first demo succeeded for the wrong reason

In the early demo, I loaded the catalogue online, disconnected the network, edited one item, reconnected, and watched the request succeed. Every assumption aligned with the happy path. There was one device, one action, one short interruption, and a server that did not change in the meantime.

I repeated the test with two actions on one item and discovered that the cached record retained only the final quantity. Then I let the request time out after the server committed it and clicked retry. The adjustment applied twice. Finally, I opened a second tab and watched two send loops compete over the same local work.

Each failure was a different version of the same mistake: the prototype had stored a view and treated submission as an instant. It lacked durable identity for the work between those moments.

The project became interesting when the demo stopped being a proof and became a fault fixture. I preserved the exact sequences as scenarios and made them the acceptance path for every later change.

Store intent, not only results

The prototype kept a local copy of each item and a boolean called pending. That worked until a person changed the same item twice before synchronization. The local record showed only the latest result. We had lost the sequence of intent that produced it.

We replaced the flag with an append-only operation log. The target tablets used a Chromium-based browser with IndexedDB support, so every local action became a durable record:

interface LocalOperation {
  operationId: string
  deviceId: string
  sequence: number
  entityId: string
  baseVersion: number
  kind: 'move' | 'adjust' | 'note'
  payload: Record<string, unknown>
  recordedAt: string
}

The interface projected its current view by applying unapplied local operations over the last confirmed server snapshot. A person could make several changes without the application flattening them into one unexplained value.

The operation ID was generated once and never changed across retries. The device sequence increased monotonically. We recorded wall-clock time for display, but did not use it to establish order between devices; field clocks were too easy to misconfigure.

This distinction between intent and projection was the most valuable design decision in the project. It later reappeared in background jobs, collaborative documents, and agent traces under different names.

The projection had two layers. A confirmed snapshot described the latest shared state the device had received. A local layer applied pending operations in their creation order. The interface could render the result quickly without collapsing the layers in storage.

That made a rebuild possible. If a derived index or optimistic view became corrupted, X8B6 discarded it, loaded the confirmed snapshot, and replayed pending operations through deterministic transition functions. It did not discard the operations themselves.

The approach also gave testing a stable seam. Fixtures could begin with a named server revision and a short local operation list, then assert both the projected quantity and the explanation shown beside it. A wrong projection was no longer an unexplained row; it was a transition sequence that could be inspected.

I kept the log bounded on the device by compacting confirmed work into later snapshots. Unconfirmed and attention-required operations were never compacted merely because they were old. Their age increased the urgency of review, not their disposability.

When a connection appeared, the client sent a batch of operations in sequence. The server processed each operation in a transaction and stored its ID beside the resulting entity version. If the same operation arrived again, the server returned the existing receipt instead of repeating the side effect.

Receipts had three meaningful outcomes:

  • Accepted: the operation applied and produced a new server version.
  • Conflict: the operation referred to a base version that no longer matched.
  • Rejected: the operation violated a rule independently of synchronization.

The client removed an operation from its outbox only after receiving a durable receipt. A timed-out request was not treated as failure, because the server might have committed before the response disappeared. It remained “outcome unknown” and was safe to resend with the same ID.

That detail made the retry button trustworthy. Without it, “try again” would have been an invitation to duplicate inventory adjustments.

The first sender uploaded every pending operation in one batch. A large offline session made the request fragile and prevented the interface from showing which item had progressed.

I changed the loop to bounded batches with results per operation. Operations kept local order within a dependency chain, while unrelated items could continue after one chain paused for review. The client committed received receipts locally before asking for the next batch.

The sender used connectivity events only as hints to begin. A browser reporting online did not guarantee that the service was reachable. A successful health request did not guarantee that an operation would be accepted. Receipt state, not the network icon, governed completion.

Backoff prevented an unavailable service from receiving a tight retry loop. Manual retry reused the same operation identities and did not reset the history of attempts. After repeated failures, the interface stopped showing animated progress and explained that work remained stored on the device.

This made synchronization less dramatic. It could make partial progress, pause one item, resume later, and leave an accountable ledger at every interruption.

Conflict was a product decision

I initially hoped to resolve conflicts with last-write-wins. It was simple, deterministic, and wrong for several important operations.

Two notes could usually coexist. Two moves of the same piece of equipment could not. An adjustment might be additive rather than absolute. We created policies by operation type instead of pretending every record shared one merge rule.

For a conflicting move, the interface showed the last confirmed location, the pending local destination, and the newer server destination. It asked the user to confirm a new move rather than silently editing history. That was slower than automatic resolution and much safer than producing a confident fiction.

We avoided the word “conflict” in the field interface. It described the implementation, not the task. The message said that the item had changed elsewhere and named the choices in the language of locations and actions.

The decision screen was not a generic merge dialog. For a move, it showed last confirmed location, local intended destination, and any accepted movement since that base. For a recount, it showed the physical count beside intervening adjustments. For two notes, it often preserved both without asking.

I initially worried that different interfaces per operation would create inconsistency. The consistency people needed was not one dialog shape. It was a dependable explanation of their work and the shared work that complicated it.

A review decision also received identity and a receipt. If shared state changed again while the screen was open, the old choice did not apply blindly. It refreshed against the new revision and, when necessary, asked again with updated context.

Conflict resolution became another offline-capable workflow rather than an emergency modal outside the data model.

Offline state needed a visible shape

The first design used a small connectivity icon. T04P had already taught me to distrust that simplification.

X8B6 displayed three separate facts:

  • Whether a network request could currently reach the service.
  • How many local operations were waiting for receipts.
  • When the last complete server snapshot had been confirmed.

A device could be online with six pending conflicts. It could be offline with no unsynchronized work. Those situations demanded different behavior and different confidence.

The sync control opened a ledger of pending, accepted, and attention-required operations. That page was not a diagnostic afterthought. People used it at the end of a shift to decide whether the device was safe to hand over.

Handoff became the main status test

The ledger answered the question the top-level icon could not: what responsibility remained on this device?

Its summary separated three groups:

Confirmed this shift: 18
Waiting to send: 3
Needs review: 1

Each waiting row named the action and item. A generic “four changes pending” count made one ambiguous recount look equivalent to four routine notes. Detail showed last attempt, local storage confirmation, and whether later work depended on it.

The handoff view offered an export of unresolved work and a deliberate “ready to hand over” state only when no unique drafts or pending operations remained. It did not sign the person out and clear storage first.

This changed the project's definition of done. Saving an adjustment was not complete when the form closed. The work reached a terminal state when it had a server receipt, had been explicitly discarded, or remained visibly assigned to the device for later review.

The ledger made local custody part of the product rather than an implementation detail users were expected to remember.

On launch, X8B6 opened and migrated IndexedDB, loaded the last confirmed snapshot, restored pending operations, and rebuilt the optimistic projection before trying the network. That order let a device become useful in a basement without showing an indefinite loading screen.

The header named when the snapshot was last confirmed. Items with local work carried operation-level status. If the service became reachable, reconciliation updated receipts and shared changes incrementally.

I resisted blocking the whole interface until synchronization finished. Offline-first would mean little if every launch waited for the network before revealing data already stored locally. At the same time, the app never relabeled the local projection as fully current merely because startup succeeded.

A corrupted derived projection could be rebuilt. A failed database migration entered recovery before touching unsent operations. A storage capability check prevented the project from promising durable offline work where the browser could not actually commit it.

Startup was the moment the architecture proved whether it understood the difference between recoverable cache and unique intent.

Once synchronization worked, IndexedDB schema changes became the frightening part. A failed deployment could leave an old client with queued operations encoded under a previous schema. Clearing storage was unacceptable because the storage contained work that existed nowhere else.

Migrations were forward-only and tested against captured databases from earlier versions. We kept operation payloads small and versioned their shape. New clients could read old operations, and the server continued accepting the previous operation version during a rollout window.

We also learned that storage quotas and private browsing modes could make persistence unavailable. The application performed a small write-read-delete check during startup. If durable local storage was not available, it did not claim offline capability. It remained usable online and explained the limitation.

That honesty reduced feature coverage and increased trust.

Releases supported old operations, not only old pages

A newly deployed server might receive an operation created by a device that had been offline through several releases. Compatibility therefore extended beyond serving the latest JavaScript bundle.

Operation payloads carried a version. The server accepted the current and previous supported forms during a documented window. Older ambiguous operations returned a review result rather than being coerced into new meaning.

Database fixtures represented every shipped local schema with pending work. A release test opened each fixture, upgraded it, rebuilt the projection, and attempted synchronization against the new server contract. Empty installation remained the easiest and least informative case.

I kept changes additive where practical. New receipt fields had defaults for old clients; new operation kinds did not alter the meaning of stored old kinds. When a breaking semantic change was unavoidable, the rollout preserved a recovery route and export before retiring support.

This was my first clear experience of a client release carrying unfinished work from the past. Backward compatibility was not only for users who delayed refreshing. It was for intentions created under a previous version and not yet delivered.

Offline-first changed the architecture's center

Calling X8B6 “offline-first” did not mean every screen worked forever without a network. Search across the entire inventory still depended on a reasonably recent snapshot. Some administrative actions stayed online-only because their conflicts were too consequential to resolve on a tablet.

The phrase meant that local work was not an error condition. The primary write path began on the device, recorded intent durably, and reconciled when communication became possible. The network was a synchronization opportunity rather than a prerequisite for pressing save.

X8B6 did not attempt to make every administrative capability available offline. Renaming global categories, deleting locations, and changing operation policy could invalidate many other devices' assumptions. Their value did not justify a complicated distributed approval model in this project.

The interface marked those actions as requiring a current connection before the person began. It did not let someone complete a long form and reveal the requirement only at submission.

Read coverage was also bounded. The device carried the inventory slice necessary for its location and recent work, not an unlimited global catalogue. Searching outside the snapshot explained that broader results required connection.

These limits made the offline promise more credible. “Works offline” became a documented set of meaningful actions with durable behavior, not a badge placed over screens that degraded unpredictably.

The project favored depth on the primary path—find, move, adjust, note, review, and hand off—over superficial offline controls everywhere.

The outcome was a different product shape

The final project was less visually dramatic than the first demo. It had a clear inventory view, an outbox ledger, operation-specific review, snapshot age, and a recovery screen. Much of its quality lived in what happened after timeouts, restarts, second tabs, and old schema upgrades.

The architecture grew from one cached table into several explicit responsibilities:

confirmed snapshot
  + pending operation log
  -> optimistic projection
 
operation sender
  + durable server receipts
  -> reconciled shared state
 
schema migrations
  + recovery export
  -> continuity across releases

There was a cost. More states needed testing. Conflict language required product judgment. Local storage became a database with migrations. Server endpoints retained operation results. The complexity was justified only for actions whose intent needed to survive interruption.

The result did not make connectivity irrelevant. It made connectivity loss an ordinary, represented condition. Work could proceed, remain attributable to one device, and eventually reach a knowable outcome without a duplicate effect.

The evaluation followed a shift, not a benchmark

I evaluated the project with a repeatable, day-shaped scenario on two modest devices. One began from a recent snapshot and lost connectivity. The other confirmed an overlapping adjustment. The offline device created independent work, slept, reopened under a newer local schema, reconnected through a response timeout, and ended with one review-required recount.

The checks followed the person's questions:

  • Could each action be found after closing and reopening the browser?
  • Did every server effect correspond to one operation identity despite retries?
  • Could the current screen distinguish confirmed and projected quantities?
  • Did unrelated work continue while one item needed review?
  • Could the handoff view account for every unfinished action?
  • Did the next release migrate the device without clearing its outbox?

I also inspected resource limits: outbox growth, snapshot size, migration time, and how the sender behaved after a long interruption. The goal was not to simulate a fleet. It was to run the full custody cycle without manual database repair.

The scenario found problems isolated unit tests had missed. A received receipt was shown as confirmed before its local transaction completed. One sign-out path cleared a draft before exporting it. A second tab reopened the old database version and blocked an upgrade again.

Fixing those paths made the final outcome quieter. At shift end, the ledger's counts matched its rows, every pending operation could be named, and “ready to hand over” meant no work existed only in the outgoing session's memory.

The project succeeded when the state could be explained after interruption, not when a sync animation finished quickly.

The breakthrough was not caching more data. It was recognizing that the local cache held unacknowledged human work and deserved the same care as a server database: stable identities, migrations, an audit trail, and explicit recovery.

X8B6 also corrected my vocabulary. A confirmed server snapshot was a cache because it could be fetched again. A pending operation was not a cache merely because it lived in the browser. It was an original record of intent. A derived optimistic table was replaceable if both the snapshot and operations survived.

Those distinctions determined deletion priority, migration behavior, handoff language, and backup format. They prevented a cleanup function from treating every object store as expendable local residue.

The personal project never needed to pretend it served thousands of devices. Its value came from making one unreliable device rigorous enough to expose the architecture hidden inside “save this form offline.”

After that, I stopped asking whether an application “supported offline.” I asked which actions remained meaningful without communication, how their intent survived, and what evidence allowed the user to trust the eventual result.