Table of Contents
- Key Highlights
- Introduction
- Why offline reliability matters for fitness apps
- Client-side durability: choosing IndexedDB over in-memory state
- The Pending Action model: clientActionId and the critical invariant
- Local UX semantics and the offline write path
- Foreground Synchronization Manager: execution loop, triggers, and jitter
- Service Worker responsibilities and the deliberate separation
- Backend synchronization protocol: /api/v1/sync and per-action isolation
- PostgreSQL idempotency and downstream effect control
- Authentication, cached identity, and multi-account isolation
- Real-world debugging: the Safari image asset failure
- Load testing and benchmarks: validating the sync path
- Architectural trade-offs and why certain choices won
- Roadmap: Offline V2 and extensions
- Operational playbook: best practices for implementing a similar system
- What to avoid: common pitfalls and anti-patterns
- Legal and privacy considerations
- Real-world example: from athlete click to server commit
- Measuring success: metrics and instrumentation
- FAQ
Key Highlights
- Gymova’s Offline V1 guarantees local durability by writing every workout mutation to IndexedDB with a stable clientActionId, then synchronizing asynchronously with a server API that enforces database-level idempotency.
- The system relies on a foreground Synchronization Manager — not service-worker background sync — combining reconnect jitter, batched requests, and transaction-isolated processing on the backend to prevent duplicates and protect derived metrics like streaks and XP.
Introduction
Fitness applications must keep user trust by never losing workout data, even when gym Wi‑Fi drops, phones switch networks, or browser tabs close unexpectedly. Gymova engineered an offline-first logging architecture that treats the device as the authoritative local store at the time of the user action. The result: athletes see immediate confirmation that their effort is captured, and the platform ensures the server receives and applies each action exactly once, even if clients retry repeatedly or network responses are lost.
This article reconstructs Gymova’s Offline V1 design, explaining the client-side persistence model, the pending action shape and invariants, the foreground synchronization strategy, backend sync protocol, and database-level idempotency. It includes operational details, debugging lessons from real devices, load-test outcomes, and the trade-offs that shaped pragmatic choices. Engineers building data-sensitive mobile or web apps will find concrete patterns to adapt for their own offline-first flows.
Why offline reliability matters for fitness apps
Gyms, tracks, and training facilities present a hostile environment for networked applications. Concrete walls, dense crowds of devices, and intentional router configurations produce intermittent connectivity. Users perform sets, log weights and reps, and expect their effort to be recorded. Losing that data ruins trust.
Relying on synchronous, in-flight HTTP acknowledgements as proof of persistence is fragile. A request can be accepted and the server commit a transaction while the client never receives the response. Conversely, a client can time out or crash before a request ever reaches the network. The only robust definition of a successful offline write is that the action is durably stored on the local device. Gymova’s approach accepts that local durability is the promise to users; server synchronization is an eventual, guaranteed process.
Real-world consequence: if an athlete logs a personal record at a busy gym and the app reports a server sync failure while having no local record, the athlete loses that entry. Gymova's design avoids this by capturing intent locally first, then ensuring server-side processing is idempotent.
Client-side durability: choosing IndexedDB over in-memory state
In-memory state management (React useState, Redux) is convenient but ephemeral. Tab reloads, browser crashes, or OS memory reclamation erase the state. For durable offline writes the client must persist structured data to a transactional store that survives process restarts.
Gymova selected IndexedDB for the following reasons:
- It supports transactional operations and structured object stores.
- It is available in major browsers and supports storage of nested JSON payloads.
- It persists across reloads and browser restarts, enabling reliable recovery and offline UX.
The client database schema is intentionally minimal and focused:
- Database name: gymova-offline
- Object stores:
- pendingActions: unsynchronized mutation actions queued for delivery
- cachedWorkoutPlan: cached plan data to render UI while offline
- meta: small metadata like lastUserId used for account isolation
Design notes:
- Pending actions are persisted immediately at the moment a user completes the workout logging flow. That write is the definition of success from the user's point of view.
- IndexedDB operations are asynchronous. The client-side codebase consolidates a small, well-tested helper layer that wraps IndexedDB transactions and prevents subtle concurrency bugs.
Practical tip: wrap IndexedDB access with a thin promise-based API and adopt strict locking or single-writer semantics for each object store. That reduces race conditions when multiple components try to enqueue pendingActions concurrently.
The Pending Action model: clientActionId and the critical invariant
Every mutation enqueued for later synchronization is represented with a fixed schema. The essential fields:
- clientActionId (UUID): generated once when the action is queued and remains constant across retries.
- userId: who performed the action; used for local account isolation.
- type: e.g., LOG_WORKOUT_EXERCISES.
- payload: the actual data (workoutDayItemId, exercises, sets).
- createdAt: timestamp of the local creation.
- attemptCount, lastAttemptAt: sync metadata.
- status: 'pending' or 'failed'.
- lastError: optional object to preserve recent failure details.
The single non-negotiable invariant: clientActionId is immutable once generated. That value is the contract between client and server that enables idempotent processing. If the client regenerated the identifier for each retry, the server could not distinguish retries from new actions.
Real-world analogy: consider email delivery. An email client marks a message "queued" locally, assigns a stable message-id, and retries delivery until the mail server accepts it. If the client assigned a new id each retry, the server could accept the same content multiple times as distinct messages, filling the recipient's inbox with duplicates. clientActionId functions the same way.
Implementation details to adhere to:
- Use a high-quality UUID generator on the client. Collisions are extremely unlikely; still, the server enforces uniqueness as a safety net.
- Generate clientActionId synchronously at UI action completion, before any network operations.
- Record userId and createdAt to aid auditing and conflict resolution.
Local UX semantics and the offline write path
Users deserve accurate status messaging that reflects reality: saved locally versus saved on the server. Gymova separates local and remote success semantics clearly in the UI.
Write decision logic:
- The WorkoutLogForm component triggers a useLogWorkout mutation.
- The mutation inspects the network state (navigator.onLine) and attempts an immediate HTTP POST if the device is online.
- If the device is offline or a network error occurs, the mutation writes the PendingAction to IndexedDB and returns a local success state.
- UI messages: when an action is saved to IndexedDB, the app shows "Saved locally. Queued for automatic sync." If a direct HTTP request succeeds, the UI states “Workout logged and synced to server.”
Key technical choices:
- TanStack Query is configured with networkMode: 'offlineFirst' so that the client can run mutations even when browser connectivity is unreliable.
- The local change is applied optimistically to the UI only when a durable write to IndexedDB succeeds. This prevents cases where UI shows a saved workout but the local persistence failed.
UX example:
- Athlete completes sets and taps "Save".
- App generates clientActionId, writes the PendingAction to IndexedDB, updates the UI with the new workout entry and a small banner: "Saved locally — syncing when connected."
- Upon successful server sync the banner transitions to a subtle "Synced" indicator, or disappears.
This approach ensures that the athlete's observed state matches the technical truth at each stage.
Foreground Synchronization Manager: execution loop, triggers, and jitter
Gymova chose to run synchronization in the foreground context rather than relying on service workers or browser background sync APIs. The reason is determinism: when the app is visible and running, the client can control ordering, retry schedules, and user feedback explicitly. Background sync implementations are often inconsistent across browsers and devices.
The SyncManager runs as a client-side execution loop and reacts to several triggers:
- New action inserted into IndexedDB
- Window online event
- Page visibility change to visible
- Periodic heartbeat interval (every 20 seconds)
- Application boot (OfflineProvider initialization)
- User re-authentication
- Manual retry triggered by the user
This set of triggers covers common cases where sync should occur. The SyncManager ensures the queue is processed as soon as conditions allow.
Preventing a thundering herd A gym router reboot or a mass reconnection event can cause thousands of devices to attempt sync simultaneously. To avoid overwhelming the backend, Gymova applies randomized reconnect jitter at the client:
- When the network transitions to online, the SyncManager delays processing by a small random interval within a configured range before starting.
- The jitter is combined with staggered batch scheduling to smooth incoming request patterns.
Batching and retry strategy
- Maximum actions per sync batch: 25 (POST /api/v1/sync).
- Backoff algorithm: decorrelated jitter backoff, which mixes exponential backoff with randomization to avoid synchronized retry storms.
- Base delay: 1 second; max delay: 60 seconds.
- Maximum attempts: 8. Once exhausted, the action's status becomes 'failed' and the UI exposes a manual retry option.
Why batch size matters Batching balances throughput and latency. Small batches increase HTTP overhead and backend transaction costs. Very large batches complicate per-action transactional isolation and increase the blast radius of malformed requests. Gymova chose 25 as a pragmatic upper bound that keeps payloads reasonable while providing efficient resource utilization.
Practical guidelines for other teams:
- Start with conservative batch sizes and increase only after measuring backend resource contention.
- Implement per-action transactional isolation on the server to contain failures within a batch.
Service Worker responsibilities and the deliberate separation
Gymova decouples the service worker responsibilities from mutation synchronization. The service worker manages static asset caching and the PWA shell, ensuring the app can load offline. It does not take responsibility for mutating queued actions or guaranteeing their delivery.
Rationale:
- Service worker execution and lifecycle are controlled by the browser and vary across devices. Background sync and periodic sync support is inconsistent, especially on iOS.
- Complex mutation logic with retries, jitter, and account awareness is easier to implement reliably in the foreground where the app has access to user authentication state and comprehensive logging.
The service worker remains crucial for offline UX, since the application shell and static assets must be available when the user starts the app offline. Synchronization remains the app's responsibility while it runs in the foreground, providing deterministic behavior and clearer user feedback.
Backend synchronization protocol: /api/v1/sync and per-action isolation
Sync endpoint design Clients POST an array of PendingActions to /api/v1/sync with the following schema excerpt:
{ "actions": [ { "clientActionId": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d", "type": "LOG_WORKOUT_EXERCISES", "createdAt": 1772849816000, "payload": { "workoutDayItemId": "item_123", "exercises": [...] } } ] }
Server-side processing rules
- Each action in the request is processed inside an independent database transaction. That prevents one malformed action from rolling back valid ones.
- Database inserts use idempotent upsert semantics keyed by a compound unique constraint that includes athlete_id, client_action_id, and workout_day_item_id.
- The API returns per-action outcomes so the client can clear only those actions that were successfully processed.
Per-action transaction isolation reduces the risk that a single validation error or a transient downstream failure takes down the entire batch. This design enables the server to return partial success and makes client-side cleanup straightforward.
Example processing flow:
- Unpack the batch received at /api/v1/sync.
- Iterate actions sequentially (or in a controlled parallelism bound).
- For each action:
- Begin a database transaction.
- Attempt idempotent insert into workout_logs using ON CONFLICT DO NOTHING.
- If the insert modifies rows, trigger downstream derived metric recalculation (XP, streaks).
- Commit the transaction.
- Record success or failure for the action.
The server's responsibility is explicit: accept retries and handle duplicates safely.
PostgreSQL idempotency and downstream effect control
Network acknowledgements can be lost after a successful server commit. The server must be idempotent with respect to clientActionId. Gymova enforces idempotency through PostgreSQL unique constraints and careful downstream handling.
Unique constraint A compound unique constraint prevents duplicate logical inserts:
ALTER TABLE workout_logs ADD CONSTRAINT unique_client_action_per_item UNIQUE (athlete_id, client_action_id, workout_day_item_id);
The insert query uses ON CONFLICT DO NOTHING:
INSERT INTO workout_logs (id, athlete_id, client_action_id, workout_day_item_id, details) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (athlete_id, client_action_id, workout_day_item_id) DO NOTHING;
This pattern ensures that duplicate deliveries with the same clientActionId do not create multiple rows.
Downstream side-effects: XP and streaks Derived metrics must be applied exactly once relative to each unique user action. Gymova enforces this by applying side-effects only when the database insert returned new modified rows. If the insert is a no-op due to ON CONFLICT DO NOTHING, the system skips XP and streak adjustments.
Implementation strategies:
- Use RETURNING clause on insert to determine whether rows were created.
- Wrap downstream updates (XP, streaks) inside the same transaction when possible. That guarantees atomicity: either the workout log and the derived metrics both apply, or neither does.
- When downstream updates are asynchronous, record a processed flag or idempotency key that prevents double processing.
Failure modes handled
- Duplicate request that arrives after server commit: the unique constraint prevents a second log and thus downstream metrics are not applied again.
- Partial failure within a batch: per-action transactions isolate errors.
These safeguards produce predictable, correct server state even in messy network environments.
Authentication, cached identity, and multi-account isolation
Authentication in offline scenarios introduces unique challenges. Gymova keeps JWTs in HTTP-only cookies, which means client JavaScript cannot inspect token expiry. To bridge this gap, the client caches the user identity when online. That cached identity drives offline fallback for endpoints such as /auth/me.
Offline auth behavior:
- While offline, the app uses cached identity to attribute queued actions to the correct user.
- On reconnect, if /auth/me returns 401 Unauthorized, the session is terminated and the user is prompted to reauthenticate. Pending actions tied to that prior identity are cleared to prevent cross-account leakage.
Account isolation on shared devices Gymova enforces a strict isolation guarantee when multiple athletes share a device:
- If the active lastUserId changes, any unsynced pendingActions are cleared. That prevents one athlete’s queued workouts from being applied to another athlete’s account when they later sign in.
- The meta object store records lastUserId and other account-scoped metadata to help enforce this policy.
Trade-off: losing queued actions provides stronger safety compared with risking data contamination between accounts. The product team chose safety, which aligns with privacy and expectation management for shared tablet devices in gyms.
Practical implementation note: ask users to confirm when switching accounts or consider exporting a prompt explaining that any unsynced local actions will be discarded on account switch. That reduces confusion and supports informed consent.
Real-world debugging: the Safari image asset failure
During iOS Safari testing, Offline V1 functional tests passed but some UI elements (loader icons) displayed as broken images after an offline cold-start. The root cause was Next.js dynamic image optimization: /_next/image?url=... Dynamic image routes failed to match cached precache patterns, so the service worker could not serve the optimized asset while offline.
Root cause details:
- Next.js routes for dynamic images rely on runtime optimization paths that are not captured by static precache manifests.
- When the app started offline, the request to /_next/image could not be resolved by the service worker's cache, producing broken images.
Solution implemented:
- Replace dynamic image references with deterministic static asset paths that are explicitly included in the precache manifest.
- Example before/after:
// Before (dynamic path failed offline)
<Image src="/loader.png" width={48} height={48} />
// After (deterministic precached static path)
<Image src="/static/loader.png" width={48} height={48} unoptimized />
Lessons for teams:
- Test offline cold-starts on each supported browser, including iOS Safari.
- Static assets should be registered deterministically in the build manifest to guarantee availability offline.
- Service workers require careful asset listing; dynamic routes and runtime generated URLs are risky for PWA offline shells.
Load testing and benchmarks: validating the sync path
Gymova ran real-world sync benchmarks to validate the server under concurrent workloads. Key test parameters:
- Concurrent registered athletes: 30
- Simultaneous sync requests: 30
- PostgreSQL connection pool: 20
- Workload: real multi-exercise workout log payloads
Findings:
- Per-action database transactions keep contention localized. With the connection pool sized to match expected concurrency, the system remained responsive under the test load.
- The batching strategy amortized HTTP overhead while preventing single large requests from blocking other clients.
- PostgreSQL unique constraint checks scale well, but indexes and write amplification must be monitored as the number of logged actions grows.
Operational advice:
- Use realistic payloads in load tests; simple synthetic requests often mask issues induced by larger JSON payloads or more complex server-side validation.
- Monitor lock contention, transaction durations, and queueing at the DB connection pool. Increase pool size or introduce read/write routing as load grows.
Architectural trade-offs and why certain choices won
Engineering requires trade-offs. Gymova made three core decisions that reflect practical compromise and operational priorities.
-
Durability vs. complexity IndexedDB introduces asynchronous complexity and edge cases (transactions can fail, schema migrations matter), but it guarantees durability where in-memory state cannot. For a product that must never lose workout records, the added complexity is justified.
-
Account isolation vs. data retention Clearing unsynced actions on account switch prevents data leakage between athletes who share a device. The consequence is potential data loss when users switch accounts unintentionally. The team selected privacy and correctness over preserving potentially misattributed data.
-
Foreground engine vs. background sync Background sync is convenient when available but inconsistent across browsers and devices. Implementing the sync engine in the foreground ensures deterministic behavior and stronger UX control. The trade-off: sync only occurs while the app is visible or periodically via heartbeat triggers, which may delay delivery for some workflows. For a fitness app where the athlete typically uses the device during sessions, foreground sync delivers timely results with manageable complexity.
Engineers should weigh these trade-offs against their product context. For example, a messaging app may prioritize background delivery more heavily than a workout logging app.
Roadmap: Offline V2 and extensions
Gymova’s roadmap targets deeper offline coverage and stronger multi-tab coordination:
- Offline V2 will extend offline capabilities to body progress recording and exercise catalog caching so athletes can access historical progress and full exercise metadata while disconnected.
- Cross-tab locking: implement the Web Locks API or BroadcastChannel to coordinate sync across multiple open tabs. Without coordination, multiple tabs can schedule overlapping sync operations and cause inefficient retries.
- Plan versioning (planVersion): pass server plan versions in sync payloads to handle cases where a workout plan changes while the client is offline. This prevents applying scores or logs against stale or modified plans and enables conflict detection.
Additional features to consider:
- Background sync integration as an optional enhancement where supported, falling back to foreground sync otherwise.
- Progressive reconciliation that identifies plan drift and offers lightweight conflict resolution UI to athletes when local actions reference substantially different server plans.
Operational playbook: best practices for implementing a similar system
If your team builds an offline-first mutating flow, follow these practical steps:
-
Define the local durability contract Decide what local persistence guarantees mean to users. For Gymova, a local write to IndexedDB equals "saved". Make that convention explicit in the UI.
-
Generate a stable clientActionId at the moment of intent Prevent regenerating ids on retry. Store clientActionId with the action and use it as the idempotency key on the server.
-
Keep the client schema compact Store only the minimal fields needed for identity, payload, and retry metadata. Heavy denormalization complicates migrations.
-
Implement a deterministic foreground sync manager Tie sync triggers to events the app controls: new actions, online events, visibility, periodic heartbeat, and explicit user retry.
-
Use randomized reconnect jitter and decorrelated backoff Avoid synchronized retries by mixing random delays into reconnect and retry logic.
-
Enforce per-action server transactions and idempotency in the DB Process each action inside its own transaction and use a unique constraint to prevent duplicates.
-
Apply downstream side-effects only on new inserts Check whether an insert actually created rows, and only then update XP, streaks, or other metrics.
-
Test offline cold-starts across browsers, especially on iOS Safari Service worker asset lists must include any static assets the offline shell depends on.
-
Plan for account isolation When devices can be shared, clear unsynced actions on account switches or require explicit transfer of queued actions.
-
Monitor and load test with realistic payloads Simulate real concurrent actors and payload sizes; watch DB contention and long transactions.
What to avoid: common pitfalls and anti-patterns
Several mistakes repeatedly cause failures in offline-first systems:
- Treating HTTP success as the only persistence guarantee. If the client shows success without local durability, data may be lost on crash.
- Regenerating idempotency keys across retries. That defeats server-level deduplication.
- Relying solely on service workers for sync. Browsers differ; background sync support is not universal.
- Performing global transactions across multiple actions inside a batch that make a single malformed action abort the whole batch. Per-action isolation reduces blast radius.
- Running optimistic UI updates before a durable write to IndexedDB completes.
Avoid these by adhering to the durable local write-first pattern and enforcing server-side idempotency.
Legal and privacy considerations
Offline storage raises privacy responsibilities. Client stores may contain sensitive workout data and personally identifiable information. Follow these practices:
- Encrypt highly sensitive fields if the business requires it and the threat model demands it.
- Clear local caches on logout, account switches, or when user opts out.
- Minimize data retained offline — only keep what’s necessary for the user experience.
- Communicate offline data policies transparently in app settings and privacy documentation.
For shared gym devices, consider implementing device-level controls and an explicit "clear local data" button to let staff reset the device between users.
Real-world example: from athlete click to server commit
Walkthrough of a complete cycle for a LOG_WORKOUT_EXERCISES action:
- Athlete finishes sets and taps Save.
- Client code constructs the payload and generates a clientActionId UUID.
- PendingAction object is written to IndexedDB pendingActions store.
- The UI updates to show the workout entry and "Saved locally. Queued for automatic sync."
- The SyncManager detects a new action insertion and schedules a sync (with potential jitter if the connection just came online).
- If network available, the SyncManager batches up to 25 pending actions and posts to /api/v1/sync.
- Server processes each action in an independent transaction:
- Attempt idempotent insert into workout_logs using ON CONFLICT DO NOTHING.
- If inserted, recalculate XP and streaks in the same transaction or gated atomic flow.
- Server returns per-action success/failure payload.
- Client updates IndexedDB: removes entries that succeeded, marks others with attemptCount and potential lastError, or marks 'failed' if attempts exhausted.
- UI updates: successful ones get the "Synced" status removed; failures show a manual retry control.
This path ensures that even if step 6's HTTP response is lost, the client still holds the pendingActions entry and will retry later. The server's idempotency ensures no double counting if the client retries after a successful commit.
Measuring success: metrics and instrumentation
To assess offline-first reliability, track the following metrics:
- Local write success rate: percent of attempted saves that succeed in writing to IndexedDB.
- Sync success rate: percent of queued actions that eventually reach server with success.
- Duplicate suppressions: count of incoming sync actions that hit the unique constraint (indicates retries).
- Median sync latency: time from local write to server commit.
- Stale action age: distribution of how long actions remain queued before sync.
- Failed action rate: actions that exhaust retry budget and require manual retry.
Instrument both client and server to surface these metrics to operational dashboards. Logging clientActionId in traces supports cross-referencing client and server logs for debugging.
FAQ
Q: Why not use service worker background sync to guarantee delivery? A: Background sync is useful when available, but its support varies by browser and operating system. Foreground sync offers deterministic execution while the app is active and provides clearer user feedback. Gymova opts for a foreground engine for reliability and operational predictability, while still using a service worker for asset caching and the PWA shell.
Q: How does the server detect and avoid duplicate processing? A: The server enforces a compound unique constraint at the PostgreSQL layer (athlete_id, client_action_id, workout_day_item_id). Inserts use ON CONFLICT DO NOTHING. Side-effects like XP or streaks run only when the insert reports affected rows, ensuring downstream effects execute exactly once.
Q: What happens if a user's session expires while there are pending actions? A: Gymova caches the user identity for offline attribution, but if /auth/me returns 401 on reconnect, the session is terminated and the user must reauthenticate. Pending actions are cleared to prevent cross-account application of actions. This prevents privacy issues on shared devices.
Q: How do you prevent overloaded servers when many devices reconnect simultaneously? A: The SyncManager adds randomized reconnect jitter and staggers sync attempts. Batching and decorrelated jitter backoff prevent synchronized retries. The server also processes each action in per-action transactions, reducing the impact of malformed requests.
Q: Why batch size set to 25? A: The batch limit balances HTTP overhead and backend work per request. A larger batch might improve throughput but increases the risk of long-running transactions and complicates per-action isolation. Twenty-five is a practical starting point that can be tuned based on observed load.
Q: How do you handle derived metrics like streaks when actions arrive out of order? A: Derived metric calculation uses atomic checks against the current stored state. Because inserts are idempotent, update logic recalculates based on the committed state. If plan versions changed while offline, Offline V2 planVersion support will detect potential drift and handle reconciliation.
Q: How do you test offline cold-starts across browsers? A: Simulate offline conditions by fully clearing service worker caches and loading the PWA shell while the network is offline. Verify that all UI assets (icons, loaders, static images) are precached deterministically. Include iOS Safari in test matrices because its service worker implementation has unique constraints.
Q: How do you coordinate sync across multiple tabs? A: Gymova plans to implement cross-tab coordination via the Web Locks API or BroadcastChannel. Until then, each tab runs its own SyncManager with idempotent server semantics to avoid duplicate side-effects. Coordination reduces redundant network traffic and avoids overlapping retries.
Q: What are recommended retry/backoff parameters? A: Use decorrelated jitter backoff with a small base delay (e.g., 1 second), a maximum delay (e.g., 60 seconds), and a bounded retry budget (e.g., 8 attempts). Fine-tune these values against real-world failure patterns and server capacity.
Q: Is IndexedDB secure for storing workout data? A: IndexedDB is origin-scoped and inaccessible to other origins, but it is not encrypted by default. If sensitive data requires encryption at rest, implement client-side encryption. At minimum, clear local stores on logout, account switch, or device reprovisioning to reduce risk on shared devices.
Q: Can the server reject a batch partially? A: Yes. The server processes actions individually in independent transactions. It returns per-action statuses. The client clears only the successful actions and retains failed ones for retry or user action.
Q: How are clientActionIds generated to avoid collisions? A: Use a high-quality UUID (v4) generator on the client. Combined with the unique constraint on the server, collisions are extremely rare and handled safely.
Q: What happens if the device has limited disk space? A: IndexedDB writes can fail when storage quotas are exceeded. The client must handle write failures gracefully and inform the user to free space. Consider pruning old cached data or prompting users to sync and clear local stores.
Q: How to handle schema migrations for IndexedDB? A: Use IndexedDB versioning with upgrade handlers that migrate existing object store structures carefully. Keep migrations idempotent and test across versions to avoid corrupting queued actions.
Q: Can this architecture support offline editing or deletes? A: Yes. The PendingAction model supports any mutation type. Deletes and edits should include semantics that the server can apply idempotently, often by enforcing the same clientActionId invariants and appropriate unique keys.
Q: Does the approach work for high-frequency write workloads? A: For very high-frequency writes (e.g., continuous telemetry), batching and local aggregation may be required. IndexedDB is capable, but write throughput has limits and you should avoid writing excessively small, frequent records. Aggregate or compress high-frequency events client-side before persisting.
Q: How do you debug when a queued action never clears? A: Trace clientActionId from the client log to backend logs. Look for sync attempts, server responses, constraint errors, or validation failures. Instrumentation and correlation ids are crucial to diagnosing stuck actions.
Q: What kinds of derived metric recalculations should live in the same transaction? A: Metrics that must reflect the log atomically — for example, award XP or update a daily streak at the same time as inserting the workout log — should run inside the same transaction when feasible. This avoids transient states where a log exists without its related metrics.
Q: Should you allow users to export queued actions for recovery? A: Offering an export/import mechanism can help in unusual recovery scenarios, but it introduces complexity and privacy considerations. Prefer clear UX and robust sync guarantees first, then consider export features for power users.
Q: How does this architecture affect analytics and event pipelines? A: Server-side deduplication ensures analytics pipelines see a single logical event per user action. When collecting client-side telemetry, attach clientActionId to events to correlate telemetry with persistent logs and streamline debugging.
The Gymova Offline V1 design demonstrates that offline resilience requires coordinated guarantees at three levels: local durability, reliable delivery orchestration, and server-side authority with strong idempotency. Implementations that respect these boundaries avoid lost entries, duplicate metrics, and ambiguous UI states. The combination of IndexedDB persistence, a foreground SyncManager with jittered retries, and PostgreSQL unique constraints forms a pragmatic blueprint for any product where losing user actions undermines trust.