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
@@ -9,10 +9,15 @@
 NSString *const messageInfosKey = @"messageInfos";
 NSString *const encryptedPayloadKey = @"encryptedPayload";
 NSString *const encryptionFailureKey = @"encryptionFailure";
+NSString *const collapseIDKey = @"collapseID";
 const std::string callingProcessName = "NSE";
 // The context for this constant can be found here:
 // https://linear.app/comm/issue/ENG-3074#comment-bd2f5e28
 int64_t const notificationRemovalDelay = (int64_t)(0.1 * NSEC_PER_SEC);
+// Apple gives us about 30 seconds to process single notification,
+// se we let any semaphore wait for at most 20 seconds
+int64_t const semaphoreAwaitTimeLimit = (int64_t)(20 * NSEC_PER_SEC);
+
 CFStringRef newMessageInfosDarwinNotification =
     CFSTR("app.comm.darwin_new_message_infos");
 
@@ -130,9 +135,11 @@
     std::string rescindErrorMessage;
     try {
       @try {
-        [self
-            removeNotificationWithIdentifier:content
-                                                 .userInfo[@"notificationId"]];
+        [self removeNotificationsWithCondition:^BOOL(
+                  UNNotification *_Nonnull notif) {
+          return [content.userInfo[@"notificationId"]
+              isEqualToString:notif.request.content.userInfo[@"id"]];
+        }];
       } @catch (NSException *e) {
         rescindErrorMessage =
             "Obj-C exception: " + std::string([e.name UTF8String]) +
@@ -152,6 +159,38 @@
     publicUserContent = [[UNNotificationContent alloc] init];
   }
 
+  // Step 4: (optional) execute notification coalescing
+  if ([self isCollapsible:content.userInfo]) {
+    std::string coalescingErrorMessage;
+    try {
+      @try {
+        [self displayLocalNotificationFromContent:content
+                                   forCollapseKey:content
+                                                      .userInfo[collapseIDKey]];
+      } @catch (NSException *e) {
+        coalescingErrorMessage =
+            "Obj-C exception: " + std::string([e.name UTF8String]) +
+            " during notification coalescing.";
+      }
+    } catch (const std::exception &e) {
+      coalescingErrorMessage = "C++ exception: " + std::string(e.what()) +
+          " during notification coalescing.";
+    }
+
+    if (coalescingErrorMessage.size()) {
+      [errorMessages
+          addObject:[NSString
+                        stringWithUTF8String:coalescingErrorMessage.c_str()]];
+      // Even if we fail to execute coalescing then public users
+      // should still see the original message.
+      publicUserContent = content;
+    } else {
+      publicUserContent = [[UNNotificationContent alloc] init];
+    }
+  }
+
+  // Step 5: (optional) create empty notification that
+  // only provides badge count.
   if ([self isBadgeOnly:content.userInfo]) {
     UNMutableNotificationContent *badgeOnlyContent =
         [[UNMutableNotificationContent alloc] init];
@@ -222,6 +261,23 @@
       continue;
     }
 
+    if ([self isCollapsible:content.userInfo]) {
+      // If we get to this place it means we were unable to
+      // execute notification coalescing with local notification
+      // mechanism in time given to NSE to process notification.
+      if (!comm::StaffUtils::isStaffRelease()) {
+        handler(content);
+        continue;
+      }
+
+      NSString *errorMessage =
+          @"NSE: Exceeded time limit to collapse a notitication.";
+      UNNotificationContent *errorContent =
+          [self buildContentForError:errorMessage];
+      handler(errorContent);
+      continue;
+    }
+
     if ([self shouldBeDecrypted:content.userInfo] &&
         !content.userInfo[@"succesfullyDecrypted"]) {
       // If we get to this place it means we were unable to
@@ -254,7 +310,8 @@
   }
 }
 
-- (void)removeNotificationWithIdentifier:(NSString *)identifier {
+- (void)removeNotificationsWithCondition:
+    (BOOL (^)(UNNotification *_Nonnull))condition {
   dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
 
   void (^delayedSemaphorePostCallback)() = ^() {
@@ -268,18 +325,62 @@
   [UNUserNotificationCenter.currentNotificationCenter
       getDeliveredNotificationsWithCompletionHandler:^(
           NSArray<UNNotification *> *_Nonnull notifications) {
+        NSMutableArray<NSString *> *notificationsToRemove =
+            [[NSMutableArray alloc] init];
         for (UNNotification *notif in notifications) {
-          if ([identifier isEqual:notif.request.content.userInfo[@"id"]]) {
-            [UNUserNotificationCenter.currentNotificationCenter
-                removeDeliveredNotificationsWithIdentifiers:@[
-                  notif.request.identifier
-                ]];
+          if (condition(notif)) {
+            [notificationsToRemove addObject:notif.request.identifier];
           }
         }
+        [UNUserNotificationCenter.currentNotificationCenter
+            removeDeliveredNotificationsWithIdentifiers:notificationsToRemove];
         delayedSemaphorePostCallback();
       }];
 
-  dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
+  dispatch_semaphore_wait(
+      semaphore, dispatch_time(DISPATCH_TIME_NOW, semaphoreAwaitTimeLimit));
+}
+
+- (void)displayLocalNotificationFromContent:(UNNotificationContent *)content
+                             forCollapseKey:(NSString *)collapseKey {
+  UNMutableNotificationContent *localNotifContent =
+      [[UNMutableNotificationContent alloc] init];
+
+  localNotifContent.title = content.title;
+  localNotifContent.body = content.body;
+  localNotifContent.badge = content.badge;
+  localNotifContent.userInfo = content.userInfo;
+
+  UNNotificationRequest *localNotifRequest =
+      [UNNotificationRequest requestWithIdentifier:collapseKey
+                                           content:localNotifContent
+                                           trigger:nil];
+
+  // We must wait until local notif display completion
+  // handler returns. Context:
+  // https://developer.apple.com/forums/thread/108340?answerId=331640022#331640022
+
+  dispatch_semaphore_t localNotifDisplaySemaphore =
+      dispatch_semaphore_create(0);
+
+  __block NSError *localNotifDisplayError = nil;
+  [UNUserNotificationCenter.currentNotificationCenter
+      addNotificationRequest:localNotifRequest
+       withCompletionHandler:^(NSError *_Nullable error) {
+         if (error) {
+           localNotifDisplayError = error;
+         }
+         dispatch_semaphore_signal(localNotifDisplaySemaphore);
+       }];
+
+  dispatch_semaphore_wait(
+      localNotifDisplaySemaphore,
+      dispatch_time(DISPATCH_TIME_NOW, semaphoreAwaitTimeLimit));
+
+  if (localNotifDisplayError) {
+    throw std::runtime_error(
+        std::string([localNotifDisplayError.localizedDescription UTF8String]));
+  }
 }
 
 - (void)persistMessagePayload:(NSDictionary *)payload {
@@ -326,6 +427,10 @@
   return !payload[@"threadID"];
 }
 
+- (BOOL)isCollapsible:(NSDictionary *)payload {
+  return payload[collapseIDKey];
+}
+
 - (UNNotificationContent *)getBadgeOnlyContentFor:
     (UNNotificationContent *)content {
   UNMutableNotificationContent *badgeOnlyContent =