fix: skip profile reset when setProfile called with same identifiers#435
Merged
evan-masseau merged 8 commits intorel/4.3.2from Apr 10, 2026
Merged
fix: skip profile reset when setProfile called with same identifiers#435evan-masseau merged 8 commits intorel/4.3.2from
evan-masseau merged 8 commits intorel/4.3.2from
Conversation
On every ActivityEvent.Resumed, the SDK was unconditionally calling setPushToken(existingToken), which re-enqueued a push-token API request. Dynamic device metadata in the request body defeated the persistent state dedup guard, and a broken equals() override on PushTokenApiRequest allowed duplicates to accumulate in the queue. Under rate limiting, this created a death spiral of retries + new enqueues. Three fixes: 1. StateSideEffects.onLifecycleEvent now compares candidate push state to stored pushState before calling setPushToken, so resumes with no actual state change are no-ops. 2. PushTokenApiRequest no longer overrides equals/hashCode with a body-comparison that was inconsistent with the parent UUID-based equality and broken by body mutation in the requestBody getter. 3. KlaviyoApiClient.enqueuePushToken supersedes any existing queued push-token request before enqueuing, ensuring at most one exists. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This reverts commit df3bf65.
evan-masseau
reviewed
Apr 7, 2026
sdk/analytics/src/main/java/com/klaviyo/analytics/networking/KlaviyoApiClient.kt
Outdated
Show resolved
Hide resolved
When setProfile() is called with identifiers already in state, it calls reset() which broadcasts ProfileReset, then sets the new identifiers. Previously, ProfileReset went through onUserStateChange() which created a new pendingProfile (anonymous-only). If the next setProfile() arrived before the debounce timer, this anonymous profile was flushed as a push-token request — producing 2 requests per setProfile call instead of 1. Fix: handle ProfileReset separately from ProfileIdentifier in onStateChange. ProfileReset now directly calls flushProfile() to flush any pending old-profile changes, but does not start a new pending profile. The subsequent identifier sets from setProfile() naturally start the new pending profile and go through normal debouncing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… calls" This reverts commit c05e3ab.
When setProfile() was called with identifiers already in state, it unconditionally called reset(), regenerating the anonymous ID. If setProfile() was called rapidly (e.g. on every screen), each call produced anonymous ID churn that triggered the flush-on-anon-ID-change guard in StateSideEffects, doubling push-token API requests. Now setProfile() only resets when the incoming identifiers actually differ from current state. Anonymous ID is the lowest-order identifier and doesn't need regeneration when higher-order identifiers match. resetProfile() remains available for explicitly clobbering all state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
evan-masseau
reviewed
Apr 7, 2026
sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt
Outdated
Show resolved
Hide resolved
jason-myers-klaviyo
added a commit
to klaviyo/klaviyo-swift-sdk
that referenced
this pull request
Apr 7, 2026
When setProfile() was called with a profile whose identifiers matched current state, it unconditionally called reset(), regenerating the anonymous ID. This caused unnecessary anonymous ID churn, triggering spurious push-token API requests — the same bug as the Android SDK (klaviyo/klaviyo-android-sdk#435). Now enqueueProfile only resets when a provided (non-nil) identifier actually differs from current state. Anonymous ID is the lowest-order identifier and doesn't need regeneration when higher-order identifiers are unchanged. resetProfile() remains available for explicitly clobbering all state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merged
5 tasks
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5582c42. Configure here.
sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt
Outdated
Show resolved
Hide resolved
evan-masseau
reviewed
Apr 8, 2026
sdk/analytics/src/main/java/com/klaviyo/analytics/state/KlaviyoState.kt
Outdated
Show resolved
Hide resolved
…rm fix) Cover the new identifiers-changed guard in setProfile: - Same identifiers (each individually + all together) → no reset, anonymousId preserved - Different identifiers (each individually) → reset fires, new anonymousId - Repeated calls with same identifiers → no spurious resets (Wyze scenario) - Same identifiers + different attributes → no reset - One identifier changed among many → reset fires - Fresh state with no prior identifiers → no reset path taken - All-null identifiers when state has non-null → reset fires - Explicit reset() → still clobbers all state Part of MAGE-473 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PersistentObservableString trims whitespace on set and rejects empty values, so state stores normalized identifiers. But the identifiers-changed comparison was using raw profile values, so whitespace-padded inputs would never match and reset() would fire unnecessarily. Apply the same trim + empty-to-null normalization in the comparison. Part of MAGE-473 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract currentIds/incomingIds lists for clearer comparison logic. Includes trim normalization to match PersistentObservableString and isIdentified guard to skip comparison on fresh state. Part of MAGE-473 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
evan-masseau
approved these changes
Apr 10, 2026
evan-masseau
pushed a commit
to klaviyo/klaviyo-swift-sdk
that referenced
this pull request
Apr 10, 2026
When setProfile() was called with a profile whose identifiers matched current state, it unconditionally called reset(), regenerating the anonymous ID. This caused unnecessary anonymous ID churn, triggering spurious push-token API requests — the same bug as the Android SDK (klaviyo/klaviyo-android-sdk#435). Now enqueueProfile only resets when a provided (non-nil) identifier actually differs from current state. Anonymous ID is the lowest-order identifier and doesn't need regeneration when higher-order identifiers are unchanged. resetProfile() remains available for explicitly clobbering all state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ajaysubra
approved these changes
Apr 10, 2026
Contributor
ajaysubra
left a comment
There was a problem hiding this comment.
easier to review with iOS and Android code matching this closely.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Fixes excessive
POST /client/push-tokensrequests caused bysetProfile()being called repeatedly with the same identifiers (e.g. on every screen load).Root cause:
setProfile()unconditionally calledreset()whenever identifiers existed in state, even if the incoming identifiers were identical. Eachreset()regenerated the anonymous ID, which triggered the flush-on-anonymous-ID-change guard inStateSideEffects.onUserStateChange. When a push token was in state, this produced 2 push-token API requests persetProfile()call instead of the expected 1 (or 0 for unchanged profiles). Under rate limiting, this compounded into a request storm.Fix:
setProfile()now compares incoming identifiers against current state and only callsreset()when they actually differ. Anonymous ID is the lowest-order identifier — there's no reason to regenerate it when higher-order identifiers (externalId, email, phone) haven't changed.resetProfile()remains available for explicitly clobbering all state.Single-file change in
KlaviyoState.kt. All existing behavior for the on-resume push token refresh, push state deduplication, profile debouncing, and profile-consolidation-via-push-token-endpoint is preserved.Swift SDK counterpart: klaviyo/klaviyo-swift-sdk#543
Slack thread: https://klaviyo.slack.com/archives/C09DKBLQ3PX/p1775509483748149
Test plan
setProfile(same identifiers)repeatedly — confirm no spurious push-token requests (identifiers unchanged → no reset → PersistentObservableProperty suppresses no-op sets)setProfile(different identifiers)— confirm reset still fires, anonymous ID regenerated, old profile flushedsetProfile(same identifiers, different attributes)— confirm attributes still update and flush via debounceresetProfile()— confirm still clobbers all state as before🤖 Generated with Claude Code