ABS+ saves your Audiobookshelf listening position on the phone first and sends it to your server afterwards, so a chapter you listened to in a tunnel or on a plane is not lost and does not overwrite what you heard on another device in the meantime. Each play, pause and finish is written to storage before any network request is made, the server is asked what it already has before the app writes anything, and a failed send is retried with a delay that grows from one second to five minutes and survives the app being killed. This post walks through how that works in the code as it stands in the repository on November 9, 2026 (the sync files are unchanged since the 1.2.4 release of October 28), and why each rule exists.

Why syncing a listening position is harder than it looks

Audiobookshelf stores one progress record per book, and per podcast episode, on your server. The record holds the current time, the duration, a finished flag and a lastUpdate timestamp. An app that wants to keep it current sends a PATCH to /api/me/progress/<item> now and then. The naive version of that is a single line in a timer, and it fails in the places where audiobooks are actually listened to: the train with no signal, the phone that dies mid-chapter, the second phone that was left paused at yesterday's position.

Three things made me write a proper replay queue instead of the single line:

  • The server can be unreachable for hours while the audio keeps playing from a download.
  • Audiobookshelf has no conditional write, so the app cannot say "update this only if nobody changed it". Whatever it does to avoid clobbering a newer position has to be done by reading first.
  • The server answers a PATCH without returning the progress row, so the app does not learn the timestamp the server gave to its own write unless it asks again.

Step 1: write to the phone before anything touches the network

The Android player calls record on every play and pause event, when a title ends, and on a 20-second tick while audio is playing. record in ProgressSync.kt works synchronously: it builds the new position, commits it to the app's preferences with a blocking commit() (not the asynchronous apply()), and only then wakes the background worker. If the write to storage fails, the Android code raises an error ("Could not save listening progress") instead of carrying on as if it had saved, and the iPhone app shows the same message as a toast. The iPhone version, Progress.swift, has the same rule in its push function: the snapshot is encoded and written atomically to disk, and the replay task starts only if that write succeeded.

The journal keeps one entry per server, account, recipient and title, and that entry holds only the latest value. Listening to the same chapter for an hour offline therefore does not produce a queue of 180 updates to replay later; it produces one entry that says where you are now. The entry never contains a password or a token, only the position, the timestamps and the identity of the account it belongs to.

Every event also gets a timestamp that can only move forward: the larger of the phone's clock and one millisecond past the previous timestamp recorded for that title. If a phone's clock runs behind the server's, the first read from the server is stored as well, so the next offline event still lands after it rather than before it.

Step 2: ask the server first, and let a newer position win

When the worker runs it does not send the saved position straight away. It reads the server's current record for that title first. If the server's lastUpdate is newer than the one on the phone, someone listened somewhere else after this event happened, and the server's position wins: the phone adopts it and drops its own pending entry. This is last-write-wins by timestamp, chosen on purpose because it matches what a person expects. The device you used most recently decides where the book resumes.

There is one exception that took the most tests to get right. When the app sends a position and the connection drops before the answer arrives, the server may have stored the write while the app believes it failed. On the next read the app sees a record newer than its own and would wrongly conclude that another device had written it. To avoid that, the app saves the payload it is about to send (sent) before it sends it. When the server's record matches that payload, the app recognises its own write, keeps the server's timestamp as an acknowledgement, and does not treat it as a conflict. The comment in the code states the remaining gap plainly: with no conditional write in Audiobookshelf, a write by another device between the app's read and its send cannot be made atomic from the client.

After a successful PATCH the worker reads the record once more, because the response carries no progress row, and only then marks the entry as delivered. If a newer local event arrived while the request was in flight, that entry stays pending and goes out on the next pass.

Step 3: retry with a delay that survives a restart

When a request fails, the entry stays pending and gets a retry time. On Android the delay is one second, then two, four, eight and so on, up to 256 seconds, and from the tenth failed attempt on, five minutes. The iPhone code uses the same curve, min(300, 2^attempts) seconds, with the attempt counter capped at 10. The retry time is written into the journal, not kept in memory, so killing the app does not reset it, and starting the app wakes the worker (on the iPhone so do coming to the foreground and getting a connection back) without anything having to be playing. The test automaticStartupDrainsPersistedQueueWithoutPlayback covers that case: it queues a position, starts the sync without playing anything, and expects the queue to drain on its own.

Step 4: a finished book stays finished

A comment in the iPhone code records that Audiobookshelf treats an explicit isFinished: false in a write as "mark this unread" and discards the position that comes with it. For that reason the app leaves the field out of a write unless the title is finished. A second trap sits on the playback side. After the last chapter ends, the player sends late callbacks, such as a pause or a restore at position zero, and Audiobookshelf clears the completed state when the position moves back from the end. The app keeps the completed position through those passive callbacks and starts a re-read only when playback really starts again. The test passiveCompletionSurvivesButDeliberateOfflineRereadResumesAfterRestart runs exactly this: it finishes a book and a podcast episode offline, sends the late zero-position callbacks, checks the title still counts as 100 percent, then plays 24 seconds and checks that the server ends up with 24 seconds and no finished flag.

Linked accounts and the resume choice

If you have more than one account on the same server and link them for a title, ABS+ records progress for each linked account, one entry each, acknowledged and retried independently. The test that plays two titles offline and replays them expects four pending entries, two titles times two recipients, and expects all four to arrive once the connection returns. When you open a title, the app resumes from the most recent position it knows for your own account, and it offers another linked account's position only if that one is newer and more than 30 seconds apart. Offline it does not even ask the server, and uses the phone's copy.

What I would tell someone building this

  1. Write the event to storage before the network. Everything else in the design follows from that order.
  2. Keep the latest value per title, not a log of events. A queue of positions only has to get you to the last one.
  3. Record what you are about to send, so that your own successful write is never mistaken for someone else's.
  4. Persist the retry schedule. A backoff that lives in memory restarts from zero every time the system kills the app, which on a phone is often.
  5. Test the awkward interleavings by name. ProgressSyncTest.kt has 23 tests, among them an update that arrives during an in-flight first write, a token rotation while a newer event arrives, and a logout during a token refresh.

I read these files and their tests for this post but did not run the test suite today, so the behaviour described is what the code and tests say, not a fresh measurement on a device.

Try it

ABS+ is a free, open-source player for your own Audiobookshelf server, with no ads and no analytics. You can get it on Google Play, the App Store and F-Droid, and the sync code is in the repository under ProgressSync.kt for Android and Progress.swift for iPhone. More about the app is on absplus.app.

Frequently asked questions

Does ABS+ lose my position if I am offline for a whole book?

No. Every event is saved on the phone first and the newest position for each title is sent when the server is reachable again.

What happens if I listen on two phones?

The position with the newer timestamp wins. The app reads the server's record before writing and adopts a newer one instead of overwriting it.

Does progress sync need an ABS+ account?

No. ABS+ talks only to your own Audiobookshelf server, and the saved queue holds positions and timestamps, never credentials.