Skip to content
AF
Troubleshooting

Health Connect changesTokenExpired: How to Resync Without Losing Data

Last verified October 3, 2026 · 5 min read

Sources checked (7) · October 3, 2026
changesTokenExpired is a Boolean on ChangesResponse, not an exception. It is true when the token you passed to getChanges has expired, and Google documents that changes tokens "are only valid for 30 days after they're generated". When you see it, request a new token with getChangesToken, then re-read records from your last read timestamp or the last 30 days and dedupe them against what you already hold by record id. Google ranks that strategy as the most ideal.

Covered here:Health Connect

Your incremental sync calls getChanges(token) and the response has changesTokenExpired set to true. Nothing threw. There are no changes in the list, and the token you stored can no longer tell you what happened since it was issued. Google documents this case and a recovery for it. The one serious mistake is treating the flag as "nothing changed".

What Google documents#

Android's Jetpack ChangesResponse reference defines the field as "Whether requested Changes-Token has expired." and says at class level: "If changesTokenExpired is true, clients need to generate a new one. This typically happens when clients have not synced changelog for extended period of time."

The HealthConnectClient reference says the same thing from both ends:

  • On getChanges: "Provided changesToken may have expired if clients have not synced for extended period of time (such as a month). In this case ChangesResponse.changesTokenExpired will be set, and clients should generate a new changes-token via getChangesToken."
  • On getChangesToken: "Changes-tokens are only valid for 30 days after they're generated. Calls to getChanges with an expired changes-token will lead to ChangesResponse.changesTokenExpired"

Google's sync guide puts it as "an unused Changes token expires within 30 days". The two wordings differ, "unused" against "after they're generated", but they lead to the same design. Every getChanges call hands you a nextChangesToken, so an app that syncs regularly always holds a recent one. An expired token means a user went about a month without a successful sync. (All fetched October 3, 2026.)

Why this is a data-loss bug if you ignore it#

changesTokenExpired is a Boolean on a normal response. If your loop only processes response.changes and saves nextChangesToken, an expired token looks exactly like a quiet month. Upserts and deletions from that window never reach your datastore. Deletions are the worst case: a record the user removed in Health Connect stays in your app indefinitely. Google's DeletionChange reference says only the Metadata.id of a deleted record is returned, "For privacy", so you cannot recover deletions by reading records again. A record that no longer exists simply does not come back.

Google's recovery ladder#

The sync guide's token-expiration advice is to "use a sync strategy that avoids losing information in such a case", and it lists three building blocks: search your datastore for the most recently consumed record that has a Health Connect id, request records from Health Connect starting at a specific timestamp and upsert them, and request a new Changes token to reserve for next time.

It then ranks four strategies, from most to least ideal:

  1. Read and dedupe all data, "the most ideal strategy". Store the timestamp of your last read. "On token expiry, re-read all data from the most recent timestamp or for the last 30 days. Then, dedupe it against the previously read data using identifiers."
  2. Read only since the last read timestamp. Google warns this "results in some data discrepancies around the time of Changes token expiry".
  3. Delete, then read the last 30 days, which Google says "aligns more closely with what happens on the first integration".
  4. Read the last 30 days without deduping, "the least ideal strategy", which "results in having duplicate data displayed to users".

A resync that follows rung one#

The sketch below uses only documented APIs: getChanges, changesTokenExpired, nextChangesToken, hasMore, UpsertionChange.record, DeletionChange.recordId, getChangesToken with ChangesTokenRequest, and a paged readRecords. SyncStore stands for your own persistence.

suspend fun syncWeight(client: HealthConnectClient, store: SyncStore, ownPackage: String) {
    val stored = store.changesToken("weight") ?: return resyncWeight(client, store)
    var next = stored
    do {
        val response = client.getChanges(next)
        if (response.changesTokenExpired) return resyncWeight(client, store)
        response.changes.forEach { change ->
            when (change) {
                is UpsertionChange ->
                    if (change.record.metadata.dataOrigin.packageName != ownPackage) {
                        store.upsert(change.record) // keyed on metadata.id
                    }
                is DeletionChange -> store.delete(change.recordId)
            }
        }
        next = response.nextChangesToken
    } while (response.hasMore)
    store.saveChangesToken("weight", next) // only after the batch is applied
}

suspend fun resyncWeight(client: HealthConnectClient, store: SyncStore) {
    // Take the new token BEFORE re-reading, so writes made during the
    // re-read show up in the next getChanges instead of falling in a gap.
    val fresh = client.getChangesToken(ChangesTokenRequest(recordTypes = setOf(WeightRecord::class)))
    val thirtyDaysAgo = Instant.now().minus(Duration.ofDays(30))
    val since = minOf(store.lastReadTime("weight") ?: thirtyDaysAgo, thirtyDaysAgo)
    var pageToken: String? = null
    do {
        val page = client.readRecords(
            ReadRecordsRequest(
                recordType = WeightRecord::class,
                timeRangeFilter = TimeRangeFilter.between(since, Instant.now()),
                pageToken = pageToken
            )
        )
        page.records.forEach { store.upsert(it) } // dedupe on metadata.id
        pageToken = page.pageToken
    } while (!pageToken.isNullOrEmpty())
    store.saveChangesToken("weight", fresh)
}

Three choices in there are ours, not Google's. We read from whichever is further back, the last read or 30 days ago, so a user whose last read was yesterday still gets a full 30-day re-read. We take the fresh token before the re-read: Google lists getting a new token as part of the strategy but does not fix the order, and taking it first trades a possible gap for an overlap that the dedupe absorbs. And we test pageToken.isNullOrEmpty(), because Google's read guide warns that pageToken "can return an empty string "" instead of null when pagination is complete" on some versions. The same bookkeeping, written for any cursor-based source, is the incremental sync anchor recipe.

The re-read cannot recover deletions, as noted above. For records the user deleted during the expired window, rung three (delete what you imported for the last 30 days, then read again) is the only one of Google's strategies that converges. Whether that cost is worth paying depends on whether a stale record that was deleted upstream is harmful in your product.

Keep it from happening#

  • One token per type. The sync guide: "If your app consumes more than one data type independently, use separate Changes Tokens for each data type."
  • Sync on foreground. Google's sync guide says that because your app "can't get notified of new data", it should check for new data each time it becomes active in the foreground. A user who opens your app at least once a month keeps a live token.
  • Background reads need permission. Google's sync guide says apps can only read while in the foreground unless the user grants the Background Read permission. Without it, a user who never opens your app will outlive the token.
  • Mind the history permission. Android's HealthPermission reference lists getChanges among the calls that PERMISSION_READ_HEALTH_DATA_HISTORY applies to. The default read window is covered in Health Connect returns no data.

Where to go next#

The design reasoning behind cursor-based sync on both platforms, including how this compares with HealthKit's anchors, is in incremental sync. The setup this page assumes is in the Google Health Connect integration guide.

Frequently asked questions

Does getChanges throw an exception when the changes token has expired?
No. Android's reference says that in this case ChangesResponse.changesTokenExpired will be set, and clients should generate a new changes-token via getChangesToken. The call returns normally, so a loop that only reads the changes list and saves the next token will treat an expired token as a quiet period and silently skip everything that happened.
How long is a Health Connect changes token valid?
Android's getChangesToken reference says changes tokens are only valid for 30 days after they're generated, and Google's sync guide says an unused Changes token expires within 30 days. Each getChanges call returns a nextChangesToken, so an app that syncs at least every few weeks always holds a recent token.
How do I avoid duplicates when resyncing after token expiry?
Dedupe on identifiers. Google's most ideal strategy is to re-read from your last read timestamp or the last 30 days and dedupe against previously read data using identifiers. Key your datastore on each record's metadata id, so re-reading the same record updates it instead of inserting a second copy. Google also recommends setting a client record id on records your app writes.
Can a resync after changesTokenExpired recover deleted records?
Not by reading. Health Connect reports deletions only as a DeletionChange carrying the deleted record's id, and a deleted record does not come back from a fresh read. If deletions in the expired window matter for your product, use Google's delete-then-read strategy: remove what you imported for the last 30 days and read it again.

All 124 questions in Troubleshooting →

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 October 3, 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 troubleshooting · by AIFitnessAPI