Skip to main content
This flow is legacy and scheduled for significant refactoring. The current implementation documented below will change. Refer to this page for understanding the existing behavior, but expect breaking changes.

How it works

When a user taps Follow on a match, the iOS app calls POST /user/follow/match with the matchId and an optional teamId (for team-centric perspective). The backend creates a record in the cricket-match-follows table. This record has a TTL of 48 hours after the match ends, so stale follows are automatically cleaned up by DynamoDB.

Live Activity registration

If the match is currently live, the app immediately starts a Live Activity via ActivityKit. This is an async process:
1

Request Live Activity

iOS calls ActivityKit.request() to start a new Live Activity on the Lock Screen and Dynamic Island.
2

Observe push token

The app observes the pushTokenUpdates async stream on the Activity. Apple assigns a unique push token specifically for this Live Activity instance.
3

Register token with backend

Once the token arrives, iOS sends it to the backend to register in cricket-activity-subscriptions.
4

Receive updates

The ActivityPushJob now includes this user when broadcasting score updates for the match.

Team perspective switching

Users can change which team’s perspective they see via:
PATCH /user/follow/match/:id/team
This updates the teamId on the follow record. The next time MatchDisplayService formats a push payload for this user, it uses the new team perspective. The Live Activity itself does not need to be recreated.

Unfollowing

When a user unfollows:
  1. The follow record is deleted from cricket-match-follows
  2. The activity subscription is removed from cricket-activity-subscriptions
  3. The iOS app ends the Live Activity locally

Stale subscription recovery

Activity push tokens can become invalid if the user kills the app, the device restarts, or iOS terminates the Live Activity (after the 8-hour or 12-hour extended limit). The backend handles 410 Gone responses from APNs by removing the stale subscription from cricket-activity-subscriptions.
If a user re-opens the app and a followed match is still live, the app checks for an existing Live Activity. If none exists, it starts a new one and re-registers the token. This handles the case where iOS terminated the activity in the background.

Key tables

TablePurpose
cricket-match-followsTracks which users follow which matches (TTL: 48h post-match)
cricket-activity-subscriptionsMaps activity push tokens to users and matches

Key endpoints

MethodPathPurpose
POST/user/follow/matchFollow a match
DELETE/user/follow/match/:idUnfollow a match
PATCH/user/follow/match/:id/teamSwitch team perspective

Areas of improvement

1. Race condition: follow check-then-insert is not atomic

In user.ts, the follow endpoint reads with getMatchFollow() and then writes with followMatch() as two separate DynamoDB operations. If two concurrent requests arrive for the same user+match, both can pass the “already following” check and both proceed to send push-to-start notifications. The followMatch store function uses UpdateCommand (upsert), so the data layer is safe, but the duplicate push-to-start side effect is not guarded. Consider using a DynamoDB condition expression (attribute_not_exists) on the write itself and handling the ConditionalCheckFailedException to make the operation truly atomic.

2. Unfollow is not transactional across stores

In the DELETE /user/follow/match/:id handler, unfollowMatch() and removeSubscriptionsForUser() are called sequentially without a transaction. If the process crashes or the second call fails, the follow record is deleted but orphaned activity subscriptions remain in cricket-activity-subscriptions. These subscriptions will receive pushes for a user who has unfollowed.
DynamoDB TransactWriteItems cannot be used here because removeSubscriptionsForUser performs a query-then-delete pattern (unknown number of items). A pragmatic fix is to reverse the order — delete subscriptions first, then the follow record — so a retry of the unfollow still cleans up correctly. The existing TTL on subscriptions provides a safety net, but the window can be up to 48 hours.

3. Orphaned follow state after iOS crash

If the iOS app crashes after postFollow succeeds but before followedMatchIds.insert() and persistState() execute, the backend has a follow record but the client has no local knowledge of it. On next launch, restoreExistingActivities() only recovers activities that ActivityKit still has in memory — it does not reconcile with the backend. There is no “GET my follows” endpoint the client can call to resync.
Consider adding a GET /user/follows endpoint that returns the user’s active follow records. The iOS app can call this on launch to reconcile followedMatchIds with server truth.

4. No retry on activity token registration failure

In LiveActivityService.swift, postActivityToken(matchId:pushToken:) logs the error but does not retry. If this POST fails (network blip, server error), the backend never learns about the Live Activity push token and the user receives no score updates despite having an active Live Activity on their Lock Screen. The pushTokenUpdates stream only fires when the token changes, so there is no natural retry trigger.
A simple fix is to store the pending token locally and retry on a timer or on next app foreground. The push-to-start token registration (postPushToStartToken) has the same issue.

5. Stale activity check incorrectly ends activities during rain delays

There is a TODO in LiveActivityService.swift acknowledging this: the stale activity check (checkAndEndStaleActivities) ends any activity that has not received a push in 2x dismissalDelayMinutes (30 minutes). During rain delays or match suspensions, the backend legitimately sends no updates. The stale check will kill the Live Activity and remove the follow, forcing the user to re-follow when play resumes. The check should query match status before ending the activity.

6. TTL calculation uses match start time, not end time

In matchFollowStore.ts, the TTL is calculated as matchStartTime + 48 hours. For multi-day Test matches (which can last 5 days), the follow record could expire via DynamoDB TTL while the match is still in progress. The comment says “48 hours covers two full days of play plus rain delays / super overs” but this is insufficient for Test cricket.
Consider recalculating the TTL when the match transitions to completed, or using a longer buffer for multi-day formats. Alternatively, the live polling loop could extend TTLs for matches still in progress.

7. No offline follow queue on iOS

Both followMatch overloads in LiveActivityService.swift call the backend first and return nil on failure with no retry mechanism. If the user taps Follow while offline, the action silently fails. The pendingUnfollows pattern shows that offline retry was implemented for unfollows but not for follows.
Implement a symmetric pendingFollows queue persisted to UserDefaults, retried on connectivity restoration or next app foreground, matching the existing pendingUnfollows pattern.

8. Backend does not end the Live Activity on unfollow

The DELETE /user/follow/match/:id handler removes the follow record and activity subscriptions but does not send an APNs end event to dismiss the Live Activity. It relies entirely on the iOS client calling endLiveActivity(matchId:) locally. If the client never receives the HTTP response (network timeout), the Live Activity stays visible on the Lock Screen indefinitely (until the stale check fires after 30 minutes).
Sending an APNs Live Activity update with "event": "end" from the backend on unfollow would provide server-authoritative dismissal, removing the dependency on client-side cleanup.

9. removeSubscriptionsForUser issues unbounded parallel deletes

In activitySubscriptionStore.ts, removeSubscriptionsForUser queries all subscriptions for a match filtered by userId, then fires Promise.all on every delete. For a user with many devices or token rotations, this could hit DynamoDB throughput limits. Unlike removeAllSubscriptionsForMatch (which uses batchDeleteByPartitionKey with retry and backoff), this function has no throttling.
Consider reusing batchDeleteByPartitionKey or at minimum chunking the deletes into batches of 25 (DynamoDB BatchWriteItem limit) with backoff.