Offline Workout Logging App Design for Bad Gym Signal
Offline workout logging app design: save every set on the phone first, sync in the background with phone-made UUIDs, settle conflicts, test in airplane mode.
An offline workout logging app saves every set on the phone the moment it's entered, then syncs to the server in the background using an ID the phone created, so a retry never duplicates a set. It shows what's saved and what's waiting, and it never makes the user wait for the network before logging the next set.
TL;DR: Treat the phone as the source of truth for the session in progress and the server as the source of truth for history. Write each set to on-device storage first, give it a phone-made UUID, queue it, and sync with backoff. Agree conflict rules, show honest sync status, and test in airplane mode on an old Android phone.
Why does online-only workout logging fail in gyms?
It fails because gyms have bad signal: basements, thick walls, and crowded Wi-Fi. The simple design sends each set to the server when the user taps save. That works at a desk. On a gym floor it breaks in three ways, and each one teaches people the app can't be trusted.
- The request hangs. The user waits, gives up, and moves on. Was the set saved? Nobody knows.
- The request fails. An error appears mid-set, and the user has to remember to re-enter it later.
- The app is closed. Someone locks their phone between sets and returns to a reloaded app with an empty session.
A member who loses half a session's sets goes back to a notebook. Would you blame them?
We designed for this in GymFast, our gym management product with staff, member, and trainer apps. In GymFast, an in-progress workout is saved on the device, so a dropped connection in the gym doesn't lose a session. The rest of this post is the general pattern we'd use in any workout or trainer app; it isn't a description of GymFast's internals beyond that.
How should an offline workout logging app be structured?
Structure it in three layers: an on-device store, a sync queue, and the server. Each layer has one job and one rule, which keeps the logic small enough to test.
| Layer | Holds | Rule |
|---|---|---|
| On-device store | The active session and every set logged so far | Written on every tap, before anything else |
| Sync queue | Changes not yet confirmed by the server | Retried in the background |
| Server | The confirmed history | Accepts each change once, by its ID |
The phone owns the session in progress. The server owns history. Most bugs in offline apps come from blurring those two roles.
Where should the app save each set?
Save it in durable on-device storage before doing anything else. React Native and Expo apps have good options. Expo's SQLite library gives the app a database that "is persisted across restarts of your app," and its docs recommend enabling WAL journal mode for performance (Expo, SQLite). The same package includes a key-value store backed by SQLite if a full schema feels heavy.
Store the whole active session, not only the latest set: the plan, the exercise, sets, weights, reps, rest timer state, and the start time. When the app opens, check for an unfinished session and offer to resume it.
import * as SQLite from "expo-sqlite";
import * as Crypto from "expo-crypto";
const db = await SQLite.openDatabaseAsync("workouts.db");
await db.execAsync("PRAGMA journal_mode = WAL;");
export async function logSet(sessionId: string, exerciseId: string, reps: number, kg: number) {
const id = Crypto.randomUUID(); // made on the phone, used by the server to dedupe
await db.withExclusiveTransactionAsync(async (txn) => {
await txn.runAsync(
"INSERT INTO sets (id, session_id, exercise_id, reps, kg, logged_at) VALUES (?, ?, ?, ?, ?, ?)",
id, sessionId, exerciseId, reps, kg, Date.now()
);
await txn.runAsync("INSERT INTO outbox (id, kind) VALUES (?, 'set')", id);
});
return id;
}
Writing the set and its outbox entry in one exclusive transaction matters. Expo's docs note that the non-exclusive transaction helper can be interrupted by other async queries, so a set could be saved without being queued.
Why should the phone create each record's ID?
So retries can't create duplicates. If the phone sends a set three times because the network was flaky, the server must record one set, not three. The fix is to create the ID on the phone when the set is logged. The server treats that ID as unique: the first request creates the set, and later ones are recognized as repeats.
UUIDs suit this because they need no central coordination. RFC 9562 describes a UUID as 128 bits "intended to guarantee uniqueness across space and time," and recommends version 7, which is time-ordered, over older time-based versions (IETF, RFC 9562). A random version 4 UUID, like the one in the code above, is enough for deduplication; version 7 adds time order, which keeps database indexes tidier when sets arrive in bursts.
With phone-made IDs, every queued item reads "create set with this ID and these values," which is always safe to send again. That one decision removes most of the sync logic people expect to write.
How does background sync work?
A small loop sends queued changes when the network seems available, oldest first, and marks each one confirmed on success. The user never waits for it.
- Watch connectivity, and also try on a timer. Expo's NetInfo library reports connection type and quality (Expo, NetInfo), but "connected" doesn't always mean the server is reachable.
- Send changes in order, oldest first.
- On success, mark the change confirmed.
- On failure, back off and retry later. The same backoff rules that protect partner APIs apply here; see API rate limit backoff.
- When the session is finished and fully synced, clear the local copy.
Keep rest timers, the plan, and last week's weights on the phone too. Load them when the session starts, while the connection is good. A rest timer that stalls on a hanging request is as bad as a lost set.
What happens when two edits conflict?
Most workout data never conflicts, because one person logs their own sets on one phone. Conflicts appear at the edges, and each needs a rule decided in advance.
- A trainer edits a client's plan mid-session. Usually the session continues on the plan it started with, and the change applies next time.
- One user logs on two devices. Rare, but merging by set ID keeps data from both.
- A synced set is edited later. Treat the edit as a new change with its own timestamp, and let the latest edit win per field.
Firestore takes the last-write-wins approach for multiple offline changes to the same document (Firebase, offline data). That's a sensible default, but write your own rules down anyway. They're product decisions, not only technical ones.
Show sync status honestly
Keep it quiet but truthful. People relax when they can see their work is safe, and a small indicator is enough.
- "Saved on this phone" for sets not yet synced.
- "Synced" once the server confirms them.
- A clear message, with a retry button, if something has waited unusually long.
Never show a success message for something that hasn't been saved anywhere. That single rule prevents the worst kind of support ticket: "the app said it saved."
What the backend needs
The backend needs an API that accepts changes by client-made ID and ignores repeats, permissions that hold no matter what the client sends, and timestamps from both sides. GymFast's staff, member, and trainer apps share one token-authenticated mobile API, and its role hierarchy is enforced in the database with row-level security. We cover that database pattern in Supabase RLS for multi-tenant apps.
Some backends ship offline support in their client SDKs. Firestore enables offline persistence by default on Android and Apple platforms and syncs local changes when the device reconnects, though it's off by default on the web. With a backend that has no offline client, you build the local layer yourself, as above. We compare the two approaches on the Supabase vs Firebase page.
Testing offline logging
Test offline behaviour deliberately, because offline bugs never show up on office Wi-Fi.
- Log a full session in airplane mode, reconnect, and check the server.
- Kill the app mid-session and reopen it.
- Toggle the network on and off rapidly while logging.
- Log on a slow, lossy connection using network conditioning tools.
- Confirm that retried sets never appear twice.
Run the suite on an older Android phone as well as a new iPhone; storage speed and memory pressure differ more than people expect. More on building for both platforms on our Expo page.
FAQ
Should an offline fitness app use SQLite or a key-value store?
Use SQLite when you have related records, such as sessions, exercises, and sets, or need to query history on the phone. A key-value store is fine for a single active session saved as one blob. Expo's SQLite package offers both, so you can start simple and add tables later without switching libraries.
How long can a workout stay unsynced on the phone?
As long as it takes, provided the app keeps retrying and the data stays on the device. In practice most sessions sync within minutes of the member leaving the basement. Show a clear warning if something has waited days, so the user knows to open the app on good Wi-Fi.
Do gym member apps need offline support as well as trainer apps?
Yes, wherever someone logs or checks in on the gym floor. Members logging their own workouts face the same basements and crowded Wi-Fi as trainers do, and they're less forgiving of lost sets. We cover what a member app needs, beyond logging, on our gym member app page.
What should happen if a user deletes the app before syncing?
Unsynced data on the phone is lost with the app, so reduce the window. Sync as soon as a connection appears, and show the unsynced count clearly. Some apps warn on sign-out if items are still waiting. There's no way to recover data that never left the device.
Building an app for trainers or gym members? See our personal trainer app work and ask for a demo of GymFast Pro.