Skip to content
AF
Testing

Testing Offline Sync and Conflict Resolution

Last verified July 27, 2026 · 13 min read

Two sync engines in one test process, a fake server that holds no merge logic, an injected clock per client, and a seeded list of operations — that harness is the test. It is the only cheap way to reproduce what actually costs data: a watch and a phone both logging sets in a basement with no signal, flushing hours apart. Assert conservation on everything a person deliberately typed, and keep device-sourced samples in a separate suite, because there the assertion is the opposite one.

Twelve sets go into the app in a basement gym with no signal: eight tapped into the phone, four on the watch between rounds. Both clients flush on the drive home, and the session that comes back from the server has eleven. Each client believes it succeeded, and from its own point of view it did. The only party who will ever notice the missing set is the person who performed it, and by the time they go looking the number is a month old and there is nowhere to recover it from.

Your suite did not catch that and cannot, because it has one client in it. Every conflict bug lives between two, and the second client is the one you never instantiate. So the first test to write is not "does sync work":

it("keeps every set the user actually logged", async () => {
  const s = scenario({ seed: 1 });
  const phone = s.client("phone");
  const watch = s.client("watch");

  s.partition(phone, watch);                    // basement, no signal
  phone.log({ set: "A", kg: 60, reps: 8 });     s.tick("3m");
  watch.log({ set: "B", kg: 60, reps: 8 });     s.tick("4m");
  phone.edit("A", { kg: 62.5 });                s.tick("2m");
  watch.log({ set: "C", kg: 62.5, reps: 6 });

  s.reconnect(phone); s.flush();                // car park
  s.tick("88m");
  s.reconnect(watch); s.flush();                // home wifi, much later

  const history = await s.server.fold(sessionId);
  expect(history.setIds).toEqual(["A", "B", "C"]);
  expect(history.set("A").kg).toBe(62.5);
  expect(s.unaccountedEvents()).toEqual([]);    // the one that generalises
});

Two clients, one process, no devices, no radio, and no human toggling airplane mode. s.tick advances an injected clock; nothing sleeps. Run it a thousand times and it does the same thing a thousand times. The rest of this page is the harness that makes that test possible, the five scenarios past it that actually break a training log, and the point at which this stops being automatable.

The harness: two engines and a deliberately stupid server#

Instantiate your real sync engine twice in the same test process — not a mock of it, the real class, each with its own local store. Wire both to an in-memory fake server over a channel object rather than a socket, so partition, reconnect, deliver(n), drop, duplicate and reorder are method calls instead of network weather. The fake server does exactly three things: accept a batch, assign it a receive order, and hand batches back on request.

It must not contain your merge logic, and this is where an offline-sync suite most often becomes decorative. If the server folds events with the same function the client folds them with, then "the two clients converged" reduces to f(x) === f(x), which holds no matter how wrong f is. That test cannot fail, so it is not coverage — it is a green tick over an unexamined merge. Either the fold lives on exactly one side and the fake server is a dumb append-only log, or you have two genuinely independent implementations and the test is comparing them. There is no third option that means anything.

Three more things the harness needs before the scenarios below are worth writing:

  • A clock per client, injected and skewable. A watch and a phone in the same gym do not share a clock, and the whole conflict design rests on client time carrying the workout's meaning — when the set happened — while server receive time carries only ordering. Set the watch four minutes fast and re-run everything. That one line is the only thing in the suite that attacks the separation directly, and it is also what turns the 23:58 case below from a curiosity into a failure.
  • Seeded ID generation. Client-generated record IDs are load-bearing offline: the ID the watch minted for a set is what makes a retried flush a no-op rather than a second set. Random UUIDs make golden comparison and failure shrinking impossible, so derive them from the scenario seed.
  • The scenario as data. A list of (time, client, operation) tuples that the harness executes. Once a training session is a value rather than a block of imperative test code, you can permute it, generate it, and print a failing one back out as runnable source.

The five scenarios that break a training log#

The same set edited on the watch and on the phone#

The concurrent field edit, and the one everybody writes first with the wrong assertion. expect(merged.kg).toBe(phoneValue) asserts your tie-break rule against itself; the day someone inverts the comparison and updates the expectation to match, the test goes green on a regression. Assert instead that both weights are still retrievable, that the set is flagged for review, and that whichever value the fold surfaced is the same one under every delivery order.

Then assert on the whole session, not the contended field. A merge that resolves set A's weight perfectly and drops set C is still a lost set, and a test scoped to A will never see it.

The same set logged twice by someone who could not see the first one#

Half of this scenario should turn out not to be a conflict at all, and the test's job is to prove that. Log a set offline, flush it, have the flush time out after the server committed, then retry. One set, not two — the client-generated ID doubles as the idempotency key.

The other half is a genuine collision, and it looks identical in the payload: the user taps "log set" on the watch, the watch UI does not update, so they tap it again on the phone. Two IDs, two events, two real sets in the fold — plus a review prompt. The correct outcome is emphatically not deduplication.

This is where the dedupe suite and the conflict suite pull in opposite directions. Add a guard test that exists purely to fail later: one client, two identical sets logged ninety seconds apart, assert both survive. Somebody will eventually add content-hash deduplication to user-entered records to close a duplicate-set report, and that guard is the only thing standing between them and quietly deleting every genuine second set of five reps at the same weight — which is to say, most of a hypertrophy program.

A set that lands on a day the server has already closed#

The offline user is the one whose data arrives after everything downstream has decided the day is finished. Script it: the client logs a set at 23:58 local time, stays partitioned, and flushes at 00:40 the next morning after the nightly rollup for that day has already run.

Two assertions, and they are different. First, the set stays on the previous civil day — which midnight owns a record is decided by timezones and day boundaries, and an offline flush that crosses one is the most likely way in production to get it wrong. Second, the day's rollup is invalidated and recomputed rather than left as it was. A test that only checks the raw row landed will pass while the user's weekly volume chart stays wrong forever, because nothing recomputes a day nobody told it had changed. Assert on the number the user reads, not on the row you wrote.

Run the same scenario with the watch clock four minutes fast and it becomes 00:02, on the wrong side of the boundary, from a device that is not lying about anything — just slightly wrong.

A client offline across an app update#

Version skew is the steady state of a mobile fleet, and the offline user is by definition the one who upgrades late. The assertion: events serialized by every client version still in the wild fold to the right state.

The fixture must be frozen bytes captured from the shipped version and checked into the repo. If the test builds its old payload by calling today's serializer with a version flag, you are testing today's writer against today's reader, and the migration you actually broke is invisible.

The case that costs you is a field whose meaning changed rather than its shape: weight stored as bare kg in v3 and as a { value, unit } pair in v5, or a reps count that used to include warm-up sets and now does not. A shape migration is caught by any decoder. A semantics migration is only caught by asserting on the folded number, and it is the one that silently rewrites a person's personal-record history — 100 becomes 100 lb where it meant 100 kg, and nothing anywhere throws.

An edit racing a delete#

Both clients sync to establish a common base, partition, both act, both reconnect. One client corrects a set's weight; the other deletes the set, because the person decided it did not count. These are different claims — "this happened and the number was wrong" versus "this did not happen" — so keep-and-flag is the resolution, and the test asserts it in both delivery orders. Auto-resolving either way is a silent decision about whether a person's effort existed.

The nastier variant is a base that is not shared: the two clients merged different earlier concurrent pairs before diverging, so their notion of the ancestor differs. Nobody writes that interleaving by hand, which is the argument for the next section.

Generate the interleavings nobody would write#

Once a session is a list of tuples, you can generate it: a few hundred random workouts per CI run over a small alphabet — log a set, correct a weight, delete a set, partition, reconnect, deliver-k — checked against the properties below. Include the interruption sweep: deliver the first k events of a flush for every k from zero to n, kill the client, restart, resume. The failures cluster at k = 0, at k = 1, and at k = n where the batch committed but the acknowledgement was lost, and a client that applied half a received batch and died must not have advanced its cursor — or the rest of the session is gone with no record it arrived.

Exhaustive permutation stops at about six or seven operations because the count is factorial, so past that you are sampling; say so rather than implying the suite is complete. On failure, shrink: bisect the operation list while the failure persists, then print the survivor as runnable scenario code, because "seed 41273, 118 operations" is not a bug report and the same lost set at nine operations is a fix. And if a failing seed does not reproduce byte-for-byte on another machine, something in the harness is still reading a real clock — find it before you trust anything here.

The three properties, stated in sets and days#

  1. Conservation. Every set, every weight correction, every deletion the user actually performed is accounted for: present in the fold, superseded by a later event that names it, or in a tombstone with a cause. Not "no exception was thrown". Silent loss is the entire failure class on this surface, and this is the only property that catches it.
  2. Convergence. The same events in any delivery order produce the same session and the same day rollup. Assert it by permuting delivery, never by inspecting what a merge function returned.
  3. Repeatability. Same seed, same output, across runs and across machines. A harness that is only mostly deterministic is worse than one that is honestly random, because you will spend an afternoon debugging the wrong hypothesis.

One structural warning on conservation: count from the operation log the scenario emitted, which lives outside the system under test. If you count against a ledger the sync engine maintains, a bug that both loses a set and forgets it ever existed will satisfy the property while the set is gone. And resist expect(engine.conflictQueue.length).toBe(1) — that locks a refactor in place and tells you nothing about whether the user's set survived.

Device samples are a different suite, with the opposite assertion#

Why a watch's heart-rate samples and a user's logged sets need different resolution rules at all is offline-first conflict resolution's argument, and this page does not repeat it. The testing consequence is the part that belongs here, and it is a hard rule: never share an assertion helper between the two.

An assertNoDuplicates written for a step stream will one day delete a real second set of five. An assertNothingLost written for a set log will one day defend a doubled day as though it were data. Device-sourced samples go in a deduplication suite whose assertion lands on the rollup — one row, and the day's step total unchanged — because the rollup is where a doubled day shows up; the mechanics of overlapping honest sources are covered in deduplicating health data across sources. Do not run device samples through the conflict harness at all: the user cannot arbitrate whether the watch or the phone counted a flight of stairs better, so there is no merge to test and nothing for a review prompt to ask.

The one place the two suites must meet is a shared timeline. Script a set logged offline at 19:20 while a provider backfill for the same window is already queued on the server, flush both, and assert that the user's set and the device's samples both survive into the same day — the dedupe pass must not treat a hand-logged set as a duplicate of a workout the watch also recorded. That interleaving is cheap here and nearly impossible to stage on real hardware.

What this harness cannot cover#

The radio. The suite proves the merge; it says nothing about whether the flush actually runs when the phone leaves the basement. Google documents that in Doze the system "doesn't let JobScheduler run. WorkManager uses JobScheduler internally, so WorkManager tasks don't run" (developer.android.com, read 2026-07-30), and documents adb shell dumpsys deviceidle force-idle and adb shell dumpsys deviceidle unforce for forcing the state. That is an instrumented device test, not a unit test, and testing background sync is where that ladder gets drawn.

The fake server's fidelity. It is code you wrote, and it will drift from the real one. Two partial mitigations: replay the same scripted timelines against a real staging server nightly and diff the folded session, and make the fake fail the way the real system fails — including expiring the delta cursor, since the user who was offline for a fortnight is precisely the one who trips it. A fake that never expires a cursor means your full-resync fallback has never executed on any workout data; incremental sync covers what that cursor actually guarantees.

Version skew, if both instances are the same build. In production your two clients are usually two app versions, and two copies of HEAD cannot show you that. Frozen payload fixtures are the cheap substitute.

The device itself. The OS killing your process mid-write, storage pressure, a corrupted local database, a battery that dies during the flush at the end of a two-hour session. Budget one manual pass per release on two real devices with real airplane mode. Its job is not conflicts — the harness owns those now — its job is everything the harness fakes away.

Whether the review UI is comprehensible. "2 entries to review" at nine in the evening after a session is a usability question, and no assertion covers whether the person taps the right one.

Get the harness in before the merge logic gets complicated, because the scenarios above are cheap to script and nearly impossible to reproduce by hand afterwards. If you are building the logging surface underneath all of this, building a strength training app covers the product side; this page only covers proving that what someone typed in a basement is still there a week later.

Frequently asked questions

Why would adding content-hash deduplication to a workout log delete real sets?
Because two identical sets are normal. Five reps at sixty kilos, logged twice ninety seconds apart, is a person doing their second set — not a duplicate delivery. Content hashing cannot tell those apart, so the moment someone adds it to close a duplicate-set report they start silently deleting genuine work. Write a guard test now, before anyone is tempted: one client, two identical sets ninety seconds apart, assert both survive.
What should an offline workout sync test assert on?
Three properties, all stated as things a user would see. Conservation: every set, weight correction and deletion the person performed is either in the folded session, superseded by a later event that names it, or in a tombstone with a cause. Convergence: the same events delivered in any order produce the same session and the same day rollup. Repeatability: the same seed produces the same result on every machine. Count conservation from the operation log outside the engine, never from a ledger the engine keeps.
What breaks when someone is offline across an app update?
The migration you did not notice was a migration. A shape change — bare kilograms becoming a value-and-unit pair — is caught by any decoder. A meaning change is not: a reps count that used to include warm-up sets and now does not will decode cleanly and quietly rewrite the person's personal-record history. Test it with frozen bytes captured from the shipped version and checked into the repo, and assert on the folded number rather than on whether parsing succeeded.
Where does a set logged at 23:58 belong after it syncs at 00:40?
On the previous civil day, and its rollup has to be recomputed to say so. The offline flush that crosses midnight is the common way this goes wrong in production, and the test that only checks the raw row landed will pass while the weekly volume chart stays wrong, because nothing recomputes a day nobody told it had changed. Re-run the same scenario with the watch clock a few minutes fast and it becomes 00:02, from a device that is barely wrong.
Do device samples need the same conflict tests as user entries?
No, and the practical rule is to never share an assertion helper between them. A no-duplicates helper written for a step stream will one day delete a real second set of five; a nothing-was-lost helper written for a set log will one day defend a doubled day as though it were data. Device samples belong in a deduplication suite whose assertion lands on the rollup. Do not run them through the conflict harness at all — nobody can arbitrate whether the watch or the phone counted a flight of stairs better.

Keep reading

Elsewhere on the site

Pages that share this one’s concepts and sources, from other sections.

Next steps

Was this page useful?

Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.

← All testing · by AIFitnessAPI