Skip to content

fix: skip profile reset when setProfile called with same identifiers#435

Merged
evan-masseau merged 8 commits intorel/4.3.2from
fix/push-token-request-storm
Apr 10, 2026
Merged

fix: skip profile reset when setProfile called with same identifiers#435
evan-masseau merged 8 commits intorel/4.3.2from
fix/push-token-request-storm

Conversation

@jason-myers-klaviyo
Copy link
Copy Markdown

@jason-myers-klaviyo jason-myers-klaviyo commented Apr 7, 2026

Summary

Fixes excessive POST /client/push-tokens requests caused by setProfile() being called repeatedly with the same identifiers (e.g. on every screen load).

Root cause: setProfile() unconditionally called reset() whenever identifiers existed in state, even if the incoming identifiers were identical. Each reset() regenerated the anonymous ID, which triggered the flush-on-anonymous-ID-change guard in StateSideEffects.onUserStateChange. When a push token was in state, this produced 2 push-token API requests per setProfile() 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 calls reset() 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

  • Verify unit tests pass in CI
  • 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 flushed
  • setProfile(same identifiers, different attributes) — confirm attributes still update and flush via debounce
  • resetProfile() — confirm still clobbers all state as before
  • Verify rate limiting behavior improves for affected customer

🤖 Generated with Claude Code

jason-myers-klaviyo and others added 2 commits April 7, 2026 14:10
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>
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>
@jason-myers-klaviyo jason-myers-klaviyo changed the title fix: prevent push token request storm fix: prevent spurious push-token enqueues on rapid setProfile calls Apr 7, 2026
jason-myers-klaviyo and others added 2 commits April 7, 2026 14:23
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>
@jason-myers-klaviyo jason-myers-klaviyo changed the title fix: prevent spurious push-token enqueues on rapid setProfile calls fix: skip profile reset when setProfile called with same identifiers Apr 7, 2026
@jason-myers-klaviyo jason-myers-klaviyo marked this pull request as ready for review April 7, 2026 18:30
@jason-myers-klaviyo jason-myers-klaviyo requested a review from a team as a code owner April 7, 2026 18:30
@klaviyoit klaviyoit requested a review from dan-peluso April 7, 2026 18:30
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>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

evan-masseau and others added 3 commits April 9, 2026 11:26
…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 evan-masseau requested a review from ndurell April 10, 2026 01:37
Copy link
Copy Markdown
Contributor

@evan-masseau evan-masseau left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a little readability cleanup, added unit tests, and tested it manually a whooole bunch.
Created a patch release branch, and retargeted this PR to it.

I'll get a second MAGE approval before I hit merge

@evan-masseau evan-masseau changed the base branch from master to rel/4.3.2 April 10, 2026 02:55
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>
Copy link
Copy Markdown
Contributor

@ajaysubra ajaysubra left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

easier to review with iOS and Android code matching this closely.

@evan-masseau evan-masseau merged commit 721e1d2 into rel/4.3.2 Apr 10, 2026
14 checks passed
@evan-masseau evan-masseau deleted the fix/push-token-request-storm branch April 10, 2026 15:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants