diff --git a/keyserver/src/fetchers/message-fetchers.js b/keyserver/src/fetchers/message-fetchers.js
--- a/keyserver/src/fetchers/message-fetchers.js
+++ b/keyserver/src/fetchers/message-fetchers.js
@@ -2,16 +2,19 @@
 
 import invariant from 'invariant';
 
-import type { PushInfo } from 'lib/push/send-utils.js';
 import {
-  sortMessageInfoList,
+  type PushInfo,
+  type FetchCollapsableNotifsResult,
+  pushInfoToCollapsableNotifInfo,
+  mergeUserToCollapsableInfo,
+} from 'lib/push/send-utils.js';
+import {
   shimUnsupportedRawMessageInfos,
   isInvalidSidebarSource,
   isUnableToBeRenderedIndependently,
   isInvalidPinSource,
 } from 'lib/shared/message-utils.js';
 import { messageSpecs } from 'lib/shared/messages/message-specs.js';
-import { getNotifCollapseKey } from 'lib/shared/notif-utils.js';
 import {
   messageTypes,
   type MessageType,
@@ -54,15 +57,6 @@
   localIDFromCreationString,
 } from '../utils/idempotent.js';
 
-export type CollapsableNotifInfo = {
-  collapseKey: ?string,
-  existingMessageInfos: RawMessageInfo[],
-  newMessageInfos: RawMessageInfo[],
-};
-export type FetchCollapsableNotifsResult = {
-  [userID: string]: CollapsableNotifInfo[],
-};
-
 const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`;
 
 // This function doesn't filter RawMessageInfos based on what messageTypes the
@@ -72,39 +66,8 @@
   pushInfo: PushInfo,
 ): Promise<FetchCollapsableNotifsResult> {
   // First, we need to fetch any notifications that should be collapsed
-  const usersToCollapseKeysToInfo: {
-    [string]: { [string]: CollapsableNotifInfo },
-  } = {};
-  const usersToCollapsableNotifInfo: { [string]: Array<CollapsableNotifInfo> } =
-    {};
-  for (const userID in pushInfo) {
-    usersToCollapseKeysToInfo[userID] = {};
-    usersToCollapsableNotifInfo[userID] = [];
-    for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) {
-      const rawMessageInfo = pushInfo[userID].messageInfos[i];
-      const messageData = pushInfo[userID].messageDatas[i];
-      const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData);
-      if (!collapseKey) {
-        const collapsableNotifInfo: CollapsableNotifInfo = {
-          collapseKey,
-          existingMessageInfos: [],
-          newMessageInfos: [rawMessageInfo],
-        };
-        usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo);
-        continue;
-      }
-      if (!usersToCollapseKeysToInfo[userID][collapseKey]) {
-        usersToCollapseKeysToInfo[userID][collapseKey] = ({
-          collapseKey,
-          existingMessageInfos: [],
-          newMessageInfos: [],
-        }: CollapsableNotifInfo);
-      }
-      usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push(
-        rawMessageInfo,
-      );
-    }
-  }
+  const { usersToCollapsableNotifInfo, usersToCollapseKeysToInfo } =
+    pushInfoToCollapsableNotifInfo(pushInfo);
 
   const sqlTuples = [];
   for (const userID in usersToCollapseKeysToInfo) {
@@ -168,19 +131,10 @@
     }
   }
 
-  for (const userID in usersToCollapseKeysToInfo) {
-    const collapseKeysToInfo = usersToCollapseKeysToInfo[userID];
-    for (const collapseKey in collapseKeysToInfo) {
-      const info = collapseKeysToInfo[collapseKey];
-      usersToCollapsableNotifInfo[userID].push({
-        collapseKey: info.collapseKey,
-        existingMessageInfos: sortMessageInfoList(info.existingMessageInfos),
-        newMessageInfos: sortMessageInfoList(info.newMessageInfos),
-      });
-    }
-  }
-
-  return usersToCollapsableNotifInfo;
+  return mergeUserToCollapsableInfo(
+    usersToCollapseKeysToInfo,
+    usersToCollapsableNotifInfo,
+  );
 }
 
 type MessageSQLResultRow = {
@@ -1035,6 +989,7 @@
 
 export {
   fetchCollapsableNotifs,
+  pushInfoToCollapsableNotifInfo,
   fetchMessageInfos,
   fetchMessageInfosSince,
   getMessageFetchResultFromRedisMessages,
diff --git a/keyserver/src/push/send.js b/keyserver/src/push/send.js
--- a/keyserver/src/push/send.js
+++ b/keyserver/src/push/send.js
@@ -19,7 +19,12 @@
   createAndroidBadgeOnlyNotification,
 } from 'lib/push/android-notif-creators.js';
 import { apnMaxNotificationPayloadByteSize } from 'lib/push/apns-notif-creators.js';
-import type { PushUserInfo, PushInfo, Device } from 'lib/push/send-utils.js';
+import type {
+  PushUserInfo,
+  PushInfo,
+  Device,
+  CollapsableNotifInfo,
+} from 'lib/push/send-utils.js';
 import {
   stringToVersionKey,
   getDevicesByPlatform,
@@ -87,7 +92,6 @@
 import createIDs from '../creators/id-creator.js';
 import { createUpdates } from '../creators/update-creator.js';
 import { dbQuery, mergeOrConditions, SQL } from '../database/database.js';
-import type { CollapsableNotifInfo } from '../fetchers/message-fetchers.js';
 import { fetchCollapsableNotifs } from '../fetchers/message-fetchers.js';
 import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js';
 import { fetchUserInfos } from '../fetchers/user-fetchers.js';
diff --git a/lib/push/send-utils.js b/lib/push/send-utils.js
--- a/lib/push/send-utils.js
+++ b/lib/push/send-utils.js
@@ -18,10 +18,14 @@
   rawMessageInfoFromMessageData,
   createMessageInfo,
   shimUnsupportedRawMessageInfos,
+  sortMessageInfoList,
 } from '../shared/message-utils.js';
 import { pushTypes } from '../shared/messages/message-spec.js';
 import { messageSpecs } from '../shared/messages/message-specs.js';
-import { notifTextsForMessageInfo } from '../shared/notif-utils.js';
+import {
+  notifTextsForMessageInfo,
+  getNotifCollapseKey,
+} from '../shared/notif-utils.js';
 import {
   isMemberActive,
   threadInfoFromRawThreadInfo,
@@ -75,6 +79,16 @@
 
 export type PushInfo = { +[userID: string]: PushUserInfo };
 
+export type CollapsableNotifInfo = {
+  collapseKey: ?string,
+  existingMessageInfos: RawMessageInfo[],
+  newMessageInfos: RawMessageInfo[],
+};
+
+export type FetchCollapsableNotifsResult = {
+  [userID: string]: CollapsableNotifInfo[],
+};
+
 function identityPlatformDetailsToPlatformDetails(
   identityPlatformDetails: IdentityPlatformDetails,
 ): PlatformDetails {
@@ -236,6 +250,74 @@
   };
 }
 
+function pushInfoToCollapsableNotifInfo(pushInfo: PushInfo): {
+  +usersToCollapseKeysToInfo: {
+    [string]: { [string]: CollapsableNotifInfo },
+  },
+  +usersToCollapsableNotifInfo: { [string]: Array<CollapsableNotifInfo> },
+} {
+  const usersToCollapseKeysToInfo: {
+    [string]: { [string]: CollapsableNotifInfo },
+  } = {};
+  const usersToCollapsableNotifInfo: { [string]: Array<CollapsableNotifInfo> } =
+    {};
+  for (const userID in pushInfo) {
+    usersToCollapseKeysToInfo[userID] = {};
+    usersToCollapsableNotifInfo[userID] = [];
+    for (let i = 0; i < pushInfo[userID].messageInfos.length; i++) {
+      const rawMessageInfo = pushInfo[userID].messageInfos[i];
+      const messageData = pushInfo[userID].messageDatas[i];
+      const collapseKey = getNotifCollapseKey(rawMessageInfo, messageData);
+      if (!collapseKey) {
+        const collapsableNotifInfo: CollapsableNotifInfo = {
+          collapseKey,
+          existingMessageInfos: [],
+          newMessageInfos: [rawMessageInfo],
+        };
+        usersToCollapsableNotifInfo[userID].push(collapsableNotifInfo);
+        continue;
+      }
+      if (!usersToCollapseKeysToInfo[userID][collapseKey]) {
+        usersToCollapseKeysToInfo[userID][collapseKey] = ({
+          collapseKey,
+          existingMessageInfos: [],
+          newMessageInfos: [],
+        }: CollapsableNotifInfo);
+      }
+      usersToCollapseKeysToInfo[userID][collapseKey].newMessageInfos.push(
+        rawMessageInfo,
+      );
+    }
+  }
+
+  return {
+    usersToCollapseKeysToInfo,
+    usersToCollapsableNotifInfo,
+  };
+}
+
+function mergeUserToCollapsableInfo(
+  usersToCollapseKeysToInfo: {
+    [string]: { [string]: CollapsableNotifInfo },
+  },
+  usersToCollapsableNotifInfo: { [string]: Array<CollapsableNotifInfo> },
+): { [string]: Array<CollapsableNotifInfo> } {
+  const mergedUsersToCollapsableInfo = { ...usersToCollapsableNotifInfo };
+  for (const userID in usersToCollapseKeysToInfo) {
+    const collapseKeysToInfo = usersToCollapseKeysToInfo[userID];
+    for (const collapseKey in collapseKeysToInfo) {
+      const info = collapseKeysToInfo[collapseKey];
+      mergedUsersToCollapsableInfo[userID].push({
+        collapseKey: info.collapseKey,
+        existingMessageInfos: sortMessageInfoList(info.existingMessageInfos),
+        newMessageInfos: sortMessageInfoList(info.newMessageInfos),
+      });
+    }
+  }
+
+  return mergedUsersToCollapsableInfo;
+}
+
 async function buildNotifText(
   rawMessageInfos: $ReadOnlyArray<RawMessageInfo>,
   userID: string,
@@ -607,7 +689,14 @@
     [userID: string]: Promise<$ReadOnlyArray<TargetedNotificationWithPlatform>>,
   } = {};
 
-  for (const userID in pushInfo) {
+  const { usersToCollapsableNotifInfo, usersToCollapseKeysToInfo } =
+    pushInfoToCollapsableNotifInfo(pushInfo);
+  const mergedUsersToCollapsableInfo = mergeUserToCollapsableInfo(
+    usersToCollapseKeysToInfo,
+    usersToCollapsableNotifInfo,
+  );
+
+  for (const userID in mergedUsersToCollapsableInfo) {
     const threadInfos = Object.fromEntries(
       [...threadIDs].map(threadID => [
         threadID,
@@ -621,7 +710,7 @@
     const devicesByPlatform = getDevicesByPlatform(pushInfo[userID].devices);
     const singleNotificationPromises = [];
 
-    for (const rawMessageInfos of pushInfo[userID].messageInfos) {
+    for (const notifInfo of mergedUsersToCollapsableInfo[userID]) {
       singleNotificationPromises.push(
         // We always pass one element array here
         // because coalescing is not supported for
@@ -629,7 +718,7 @@
         buildNotifsForUserDevices({
           encryptedNotifUtilsAPI,
           senderDeviceDescriptor,
-          rawMessageInfos: [rawMessageInfos],
+          rawMessageInfos: notifInfo.newMessageInfos,
           userID,
           threadInfos,
           subscriptions: pushInfo[userID].subscriptions,
@@ -740,4 +829,9 @@
   });
 }
 
-export { preparePushNotifs, generateNotifUserInfoPromise };
+export {
+  preparePushNotifs,
+  generateNotifUserInfoPromise,
+  pushInfoToCollapsableNotifInfo,
+  mergeUserToCollapsableInfo,
+};