diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java
--- a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java
+++ b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java
@@ -68,6 +68,8 @@
   private static final String MMKV_KEY_SEPARATOR = ".";
   private static final String MMKV_KEYSERVER_PREFIX = "KEYSERVER";
   private static final String MMKV_UNREAD_COUNT_SUFFIX = "UNREAD_COUNT";
+  private static final String MMKV_UNREAD_THICK_THREADS =
+      "NOTIFS.UNREAD_THICK_THREADS";
   private Bitmap displayableNotificationLargeIcon;
   private NotificationManager notificationManager;
   private LocalBroadcastManager localBroadcastManager;
@@ -244,13 +246,22 @@
     for (StatusBarNotification notification :
          notificationManager.getActiveNotifications()) {
       String tag = notification.getTag();
+      Bundle data = notification.getNotification().extras;
+
+      boolean thinThreadRescind =
+          tag != null && rescindID != null && tag.equals(rescindID);
+
+      boolean thickThreadRescind = tag != null && data != null &&
+          threadID.equals(data.getString("threadID"));
+
       boolean isGroupMember =
           threadID.equals(notification.getNotification().getGroup());
+
       boolean isGroupSummary =
           (notification.getNotification().flags &
            Notification.FLAG_GROUP_SUMMARY) == Notification.FLAG_GROUP_SUMMARY;
 
-      if (tag != null && tag.equals(rescindID)) {
+      if (thinThreadRescind || thickThreadRescind) {
         notificationManager.cancel(notification.getTag(), notification.getId());
       } else if (
           isGroupMember && isGroupSummary && StaffUtils.isStaffRelease()) {
@@ -271,30 +282,38 @@
   }
 
   private void handleUnreadCountUpdate(RemoteMessage message) {
-    if (message.getData().get(KEYSERVER_ID_KEY) == null) {
-      return;
-    }
+    if (message.getData().get(KEYSERVER_ID_KEY) != null &&
+        message.getData().get(BADGE_KEY) != null) {
+      String badge = message.getData().get(BADGE_KEY);
+      String senderKeyserverID = message.getData().get(KEYSERVER_ID_KEY);
+      String senderKeyserverUnreadCountKey = String.join(
+          MMKV_KEY_SEPARATOR,
+          MMKV_KEYSERVER_PREFIX,
+          senderKeyserverID,
+          MMKV_UNREAD_COUNT_SUFFIX);
 
-    String badge = message.getData().get(BADGE_KEY);
-    if (badge == null) {
-      return;
+      int senderKeyserverUnreadCount;
+      try {
+        senderKeyserverUnreadCount = Integer.parseInt(badge);
+      } catch (NumberFormatException e) {
+        Log.w("COMM", "Invalid badge count", e);
+        return;
+      }
+      CommMMKV.setInt(
+          senderKeyserverUnreadCountKey, senderKeyserverUnreadCount);
     }
 
-    String senderKeyserverID = message.getData().get(KEYSERVER_ID_KEY);
-    String senderKeyserverUnreadCountKey = String.join(
-        MMKV_KEY_SEPARATOR,
-        MMKV_KEYSERVER_PREFIX,
-        senderKeyserverID,
-        MMKV_UNREAD_COUNT_SUFFIX);
-
-    int senderKeyserverUnreadCount;
-    try {
-      senderKeyserverUnreadCount = Integer.parseInt(badge);
-    } catch (NumberFormatException e) {
-      Log.w("COMM", "Invalid badge count", e);
-      return;
+    if (message.getData().get(SENDER_DEVICE_ID_KEY) != null &&
+        message.getData().get(THREAD_ID_KEY) != null &&
+        message.getData().get(RESCIND_KEY) != null) {
+      CommMMKV.removeElementFromStringSet(
+          MMKV_UNREAD_THICK_THREADS, message.getData().get(THREAD_ID_KEY));
+    } else if (
+        message.getData().get(SENDER_DEVICE_ID_KEY) != null &&
+        message.getData().get(THREAD_ID_KEY) != null) {
+      CommMMKV.addElementToStringSet(
+          MMKV_UNREAD_THICK_THREADS, message.getData().get(THREAD_ID_KEY));
     }
-    CommMMKV.setInt(senderKeyserverUnreadCountKey, senderKeyserverUnreadCount);
 
     int totalUnreadCount = 0;
     String[] allKeys = CommMMKV.getAllKeys();
@@ -313,6 +332,8 @@
       totalUnreadCount += unreadCount;
     }
 
+    totalUnreadCount += CommMMKV.getStringSet(MMKV_UNREAD_THICK_THREADS).length;
+
     if (totalUnreadCount > 0) {
       ShortcutBadger.applyCount(this, totalUnreadCount);
     } else {
diff --git a/native/ios/NotificationService/NotificationService.mm b/native/ios/NotificationService/NotificationService.mm
--- a/native/ios/NotificationService/NotificationService.mm
+++ b/native/ios/NotificationService/NotificationService.mm
@@ -12,6 +12,7 @@
 
 NSString *const backgroundNotificationTypeKey = @"backgroundNotifType";
 NSString *const messageInfosKey = @"messageInfos";
+NSString *const threadIDKey = @"threadID";
 NSString *const encryptedPayloadKey = @"encryptedPayload";
 NSString *const encryptionFailedKey = @"encryptionFailed";
 NSString *const collapseIDKey = @"collapseID";
@@ -22,12 +23,14 @@
 NSString *const blobHolderKey = @"blobHolder";
 NSString *const encryptionKeyLabel = @"encryptionKey";
 NSString *const needsSilentBadgeUpdateKey = @"needsSilentBadgeUpdate";
+NSString *const notificationIdKey = @"notificationId";
 
 // Those and future MMKV-related constants should match
 // similar constants in CommNotificationsHandler.java
 const std::string mmkvKeySeparator = ".";
 const std::string mmkvKeyserverPrefix = "KEYSERVER";
 const std::string mmkvUnreadCountSuffix = "UNREAD_COUNT";
+const std::string unreadThickThreads = "NOTIFS.UNREAD_THICK_THREADS";
 
 // The context for this constant can be found here:
 // https://linear.app/comm/issue/ENG-3074#comment-bd2f5e28
@@ -177,27 +180,25 @@
   }
 
   // Step 3: Cumulative unread count calculation
-  if (content.badge) {
-    std::string unreadCountCalculationError;
-    try {
-      @try {
-        [self calculateTotalUnreadCountInPlace:content];
-      } @catch (NSException *e) {
-        unreadCountCalculationError =
-            "Obj-C exception: " + std::string([e.name UTF8String]) +
-            " during unread count calculation.";
-      }
-    } catch (const std::exception &e) {
-      unreadCountCalculationError = "C++ exception: " + std::string(e.what()) +
+  std::string unreadCountCalculationError;
+  try {
+    @try {
+      [self calculateTotalUnreadCountInPlace:content];
+    } @catch (NSException *e) {
+      unreadCountCalculationError =
+          "Obj-C exception: " + std::string([e.name UTF8String]) +
           " during unread count calculation.";
     }
+  } catch (const std::exception &e) {
+    unreadCountCalculationError = "C++ exception: " + std::string(e.what()) +
+        " during unread count calculation.";
+  }
 
-    if (unreadCountCalculationError.size() &&
-        comm::StaffUtils::isStaffRelease()) {
-      [errorMessages
-          addObject:[NSString stringWithUTF8String:unreadCountCalculationError
-                                                       .c_str()]];
-    }
+  if (unreadCountCalculationError.size() &&
+      comm::StaffUtils::isStaffRelease()) {
+    [errorMessages
+        addObject:[NSString stringWithUTF8String:unreadCountCalculationError
+                                                     .c_str()]];
   }
 
   // Step 4: (optional) rescind read notifications
@@ -208,11 +209,21 @@
     std::string rescindErrorMessage;
     try {
       @try {
-        [self removeNotificationsWithCondition:^BOOL(
-                  UNNotification *_Nonnull notif) {
-          return [content.userInfo[@"notificationId"]
-              isEqualToString:notif.request.content.userInfo[@"id"]];
-        }];
+        if (content.userInfo[notificationIdKey]) {
+          // thin thread rescind
+          [self removeNotificationsWithCondition:^BOOL(
+                    UNNotification *_Nonnull notif) {
+            return [content.userInfo[notificationIdKey]
+                isEqualToString:notif.request.content.userInfo[@"id"]];
+          }];
+        } else if (content.userInfo[threadIDKey]) {
+          // thick thread rescind
+          [self removeNotificationsWithCondition:^BOOL(
+                    UNNotification *_Nonnull notif) {
+            return [content.userInfo[threadIDKey]
+                isEqualToString:notif.request.content.userInfo[threadIDKey]];
+          }];
+        }
       } @catch (NSException *e) {
         rescindErrorMessage =
             "Obj-C exception: " + std::string([e.name UTF8String]) +
@@ -505,20 +516,32 @@
 
 - (void)calculateTotalUnreadCountInPlace:
     (UNMutableNotificationContent *)content {
-  if (!content.userInfo[keyserverIDKey]) {
-    return;
-  }
-  std::string senderKeyserverID =
-      std::string([content.userInfo[keyserverIDKey] UTF8String]);
 
-  std::string senderKeyserverUnreadCountKey = joinStrings(
-      mmkvKeySeparator,
-      {mmkvKeyserverPrefix, senderKeyserverID, mmkvUnreadCountSuffix});
+  if (content.userInfo[keyserverIDKey] && content.badge) {
+    std::string senderKeyserverID =
+        std::string([content.userInfo[keyserverIDKey] UTF8String]);
+    std::string senderKeyserverUnreadCountKey = joinStrings(
+        mmkvKeySeparator,
+        {mmkvKeyserverPrefix, senderKeyserverID, mmkvUnreadCountSuffix});
+
+    int senderKeyserverUnreadCount = [content.badge intValue];
+    comm::CommMMKV::setInt(
+        senderKeyserverUnreadCountKey, senderKeyserverUnreadCount);
+  }
 
-  int senderKeyserverUnreadCount = [content.badge intValue];
-  comm::CommMMKV::setInt(
-      senderKeyserverUnreadCountKey, senderKeyserverUnreadCount);
+  if (content.userInfo[senderDeviceIDKey] && content.userInfo[threadIDKey] &&
+      [self isRescind:content.userInfo]) {
+    comm::CommMMKV::removeElementFromStringSet(
+        unreadThickThreads,
+        std::string([content.userInfo[threadIDKey] UTF8String]));
+  } else if (
+      content.userInfo[senderDeviceIDKey] && content.userInfo[threadIDKey]) {
+    comm::CommMMKV::addElementToStringSet(
+        unreadThickThreads,
+        std::string([content.userInfo[threadIDKey] UTF8String]));
+  }
 
+  // calculate unread counts from keyservers
   int totalUnreadCount = 0;
   std::vector<std::string> allKeys = comm::CommMMKV::getAllKeys();
   for (const auto &key : allKeys) {
@@ -539,6 +562,9 @@
     totalUnreadCount += unreadCount.value();
   }
 
+  // calculate unread counts from thick threads
+  totalUnreadCount += comm::CommMMKV::getStringSet(unreadThickThreads).size();
+
   content.badge = @(totalUnreadCount);
 }
 
@@ -658,10 +684,10 @@
     mutableUserInfo[needsSilentBadgeUpdateKey] = @(YES);
   }
 
-  NSString *threadID = decryptedPayload[@"threadID"];
+  NSString *threadID = decryptedPayload[threadIDKey];
   if (threadID) {
     content.threadIdentifier = threadID;
-    mutableUserInfo[@"threadID"] = threadID;
+    mutableUserInfo[threadIDKey] = threadID;
     if (mutableAps) {
       mutableAps[@"thread-id"] = threadID;
     }
@@ -678,7 +704,7 @@
 
   // The rest have been already decrypted and handled.
   static NSArray<NSString *> *handledKeys =
-      @[ @"merged", @"badge", @"threadID" ];
+      @[ @"merged", @"badge", threadIDKey ];
 
   for (NSString *payloadKey in decryptedPayload) {
     if ([handledKeys containsObject:payloadKey]) {