Synchronize safely with Rails

Design an outbox, scoped idempotency, conflicts and authorization around retryable operations.

Part 4 of 4 · Published 16 October 2026

A browser can save a field report without contacting Rails. The difficult moment arrives later: another device has edited it, and an upload response disappears after the server commits. Reliable synchronization makes these ordinary events safe through durable intent, explicit acknowledgments and rules for operations arriving twice, late or out of order.

Store intention beside the edit

Commit the report and its outbox intention in one IndexedDB transaction. Display “Saved on this device” only after completion. Each operation needs a stable UUID, account scope, record UUID, action, payload and expected server version. Retry with the same UUID and payload; never alter transmitted content under the same identity.

A separate local revision identifies the visible draft. If revision 12 uploads while revision 13 is typed, acknowledging 12 must neither erase 13 nor mark the whole report synced. Record the exact accepted operation and local revision; keep later changes pending.

Start with one operation per record in flight. Retain later draft edits behind it, then construct the next immutable operation against the newly acknowledged server version. Already-transmitted operations stay immutable; a local staged edit waiting for its predecessor is not yet such an operation. This ordering avoids rebasing an in-flight payload under its old UUID. Parallelize unrelated records only after correctness is established.

Give Rails an atomic acceptance boundary

For an illustrative POST /sync/operations endpoint, authentication establishes the user and tenant scope. A submitted account label grants no authority. Use a receipt table with a database-enforced unique key scoped to the authenticated tenant, actor and operation UUID, plus a normalized request digest and committed response.

The proposed acceptance transaction is:

  1. Resolve the authenticated scope and look for its receipt. Do not expose another scope’s receipt or let a lookup bypass authorization.
  2. If the receipt exists, reject UUID reuse with different content; return a matching result only after checking current authorization to that result. Do not apply the effect again.
  3. For a new operation, authorize the record and action, then conditionally mutate only if the expected version matches.
  4. Write the change-feed entry and successful receipt in the same database transaction as the mutation.
  5. Return acknowledgment only after commit.

Duplicate requests racing each other need the unique constraint and a deliberate retry/receipt lookup path. An “exists?” check alone is insufficient. Replayed deletion receipts may need authorization to the receipt or a scoped tombstone because the live record no longer exists; design that explicitly rather than bypassing checks.

Active Record transactions cover one database connection, not a browser request, second database or external service. A uniqueness failure can leave a transaction unusable; handle it outside the failed transaction and retry according to the database’s semantics.

If acceptance triggers a durable external effect, write a server outbox entry in the same transaction. Its worker needs its own idempotency policy. An after-commit callback alone leaves a crash window before an external delivery.

A timeout means the outcome is unknown

If Rails commits operation A but its response is lost, retain and retry A. A matching receipt prevents reapplying its effect. Retire A only after receiving the acknowledgment and recording it locally. This is at-least-once delivery with deduplicated application effects, not exactly-once networking.

Define receipt retention. If a client can return after receipts expire, it needs an expiry or resynchronization rule; forgotten identities can make old retries dangerous. Retry transient network failures and suitable server failures with capped exponential backoff and jitter, respecting server retry delays. Validation errors need correction, authorization failures need attention, and version conflicts need reconciliation.

Show pending, sending, needs sign-in, needs review and synchronized states. “Offline” is only one reason work remains pending.

Reconcile without trusting device clocks

Each update carries its base server version. Rails optimistic locking uses lock_version and can raise ActiveRecord::StaleObjectError. The submitted expected version must participate in the conditional write; loading the current record and ignoring that supplied value defeats conflict detection.

Return a structured conflict with the current authorized version. Preserve the local proposal and its base where available, then present alternatives for review. Automatic field merging is appropriate only when domain rules establish independence. Device clocks and reconnect order cannot decide whose paragraph is better.

Deletion is a versioned mutation. Keep tombstones so disconnected devices learn about deletion and old edits do not silently resurrect records. Define retention and a too-old-cursor procedure: fetch a fresh authorized snapshot, preserve unsynced proposals separately, and reconcile before upload.

Use a cursor that cannot skip commits

A paginated feed needs an opaque cursor with explicit committed-change semantics. Apply each page and advance its cursor in one local transaction; replay safely after a crash.

A naive auto-increment ID can be allocated before commit. If transaction A allocates 41 but stalls, B may commit 42 first. A client advancing to 42 can later miss 41. A proposed solution is an account-scoped revision lock acquired before allocation and held through the mutation/feed transaction’s commit. Every writer must follow the same protocol. Its throughput cost and failure behavior need testing; a sequence value alone supplies no commit-order guarantee.

Authorization changes also need an explicit protocol. A record disappearing from a filtered feed does not tell a device to remove its old copy. Use revocation messages or a scope reset, avoid cross-tenant pagination leaks and preserve pending proposals appropriately. Previously downloaded plaintext on an offline device cannot be retroactively recalled by a server response.

Foreground sync is the baseline

Try synchronization when the application opens, returns to the foreground, receives a connectivity hint or the user selects “Sync now.” navigator.onLine is not proof the Rails endpoint is reachable.

Background Sync has limited availability. The durable queue and bounded sync routine must work without it. The service-worker lifecycle permits termination: never keep the only progress record in memory or promise that closing the application guarantees delivery.

Transport replay is not acknowledgment

The Background Sync draft leaves retry timing and limits to the browser, and registration can fail. If Workbox is selected, its Background Sync documentation says the default plugin queues thrown fetch failures, not received 4xx or 5xx responses; its fallback retries when the service worker starts, and retained entries can expire. This is a conditional integration note, not a claim that Workbox is installed.

Keep the semantic outbox until a valid Rails acknowledgment is committed locally. Neither transport registration nor transport-queue expiry is acknowledgment. Recover pending work when the application reopens, and expose permanent rejection separately from retryable failure. Proposed tests, not run here: network exceptions, 401/403/409/422/429/503 responses, a lost response after server commit, denied registration, unsupported native sync, worker termination, transport retention expiry and reopening. Observe the transport queue separately from application intent. Inject failures on the real service-worker request path; changing navigator.onLine alone does not test delivery.

Protect account boundaries

Scope local data and queues by account. On logout, stop sending, invalidate the local session generation and prevent late responses from repopulating cleared stores. Explain unsynced work and offer a policy-appropriate export before deliberate deletion. Switching users must never replay the previous user’s operations under the new session.

Keep Rails CSRF protections for cookie-authenticated requests. Same-origin script can access local data; XSS prevention and limited sensitive-data retention remain necessary. Browser storage is not automatically a vault. Follow the Rails security guide and our tenant and identity model.

Web Locks can coordinate same-origin tabs where available. That reduces duplicate work; server idempotency must remain correct without it.

Test the failures, not just the happy path

Assert that no acknowledged revision disappears, no operation crosses accounts, and unresolved work stays visible. Use real database concurrency tests for receipt uniqueness and feed ordering. These tests are requirements for the proposed implementation, not claims that this article executed them.


Provided as is, for information only, with no warranty and no liability. Not legal or professional advice. Security and radio material is for authorized, lawful use only. Full disclaimer.

Visits are logged on our own server: IP address, browser and device details, and pages viewed. We use them to understand traffic and spot attacks, and never share or sell them.