diff --git a/keyserver/src/creators/update-creator.js b/keyserver/src/creators/update-creator.js
--- a/keyserver/src/creators/update-creator.js
+++ b/keyserver/src/creators/update-creator.js
@@ -8,16 +8,14 @@
   keyForUpdateInfo,
   rawUpdateInfoFromUpdateData,
 } from 'lib/shared/update-utils.js';
+import type { UpdateInfosRawData } from 'lib/shared/updates/update-spec.js';
 import { updateSpecs } from 'lib/shared/updates/update-specs.js';
 import {
-  type RawEntryInfos,
-  type FetchEntryInfosBase,
   type CalendarQuery,
   defaultCalendarQuery,
 } from 'lib/types/entry-types.js';
 import {
   defaultNumberPerThread,
-  type FetchMessageInfosResult,
   type MessageSelectionCriteria,
 } from 'lib/types/message-types.js';
 import {
@@ -25,7 +23,7 @@
   redisMessageTypes,
   type NewUpdatesRedisMessage,
 } from 'lib/types/redis-types.js';
-import type { RawThreadInfo, RawThreadInfos } from 'lib/types/thread-types.js';
+import type { RawThreadInfo } from 'lib/types/thread-types.js';
 import { updateTypes } from 'lib/types/update-types-enum.js';
 import {
   type ServerUpdateInfo,
@@ -33,7 +31,7 @@
   type RawUpdateInfo,
   type CreateUpdatesResult,
 } from 'lib/types/update-types.js';
-import type { UserInfos, LoggedInUserInfo } from 'lib/types/user-types.js';
+import type { UserInfos } from 'lib/types/user-types.js';
 import { promiseAll } from 'lib/utils/promises.js';
 
 import createIDs from './id-creator.js';
@@ -484,27 +482,12 @@
   });
 }
 
-export type UpdateInfosRawData = {
-  threadInfos: RawThreadInfos,
-  messageInfosResult: ?FetchMessageInfosResult,
-  calendarResult: ?FetchEntryInfosBase,
-  entryInfosResult: ?RawEntryInfos,
-  currentUserInfoResult: LoggedInUserInfo,
-};
 async function updateInfosFromRawUpdateInfos(
   viewer: Viewer,
   rawUpdateInfos: $ReadOnlyArray<RawUpdateInfo>,
   rawData: UpdateInfosRawData,
 ): Promise<FetchUpdatesResult> {
-  const {
-    threadInfos,
-    messageInfosResult,
-    calendarResult,
-    entryInfosResult,
-    currentUserInfoResult,
-  } = rawData;
-  const updateInfos = [];
-  const userIDsToFetch = new Set();
+  const { messageInfosResult, calendarResult } = rawData;
 
   const rawEntryInfosByThreadID = {};
   for (const entryInfo of calendarResult?.rawEntryInfos ?? []) {
@@ -522,114 +505,23 @@
     rawMessageInfosByThreadID[messageInfo.threadID].push(messageInfo);
   }
 
-  for (const rawUpdateInfo of rawUpdateInfos) {
-    if (rawUpdateInfo.type === updateTypes.DELETE_ACCOUNT) {
-      updateInfos.push({
-        type: updateTypes.DELETE_ACCOUNT,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        deletedUserID: rawUpdateInfo.deletedUserID,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD) {
-      const threadInfo = threadInfos[rawUpdateInfo.threadID];
-      if (!threadInfo) {
-        console.warn(
-          "failed to hydrate updateTypes.UPDATE_THREAD because we couldn't " +
-            `fetch RawThreadInfo for ${rawUpdateInfo.threadID}`,
-        );
-        continue;
-      }
-      updateInfos.push({
-        type: updateTypes.UPDATE_THREAD,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        threadInfo,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS) {
-      updateInfos.push({
-        type: updateTypes.UPDATE_THREAD_READ_STATUS,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        threadID: rawUpdateInfo.threadID,
-        unread: rawUpdateInfo.unread,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.DELETE_THREAD) {
-      updateInfos.push({
-        type: updateTypes.DELETE_THREAD,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        threadID: rawUpdateInfo.threadID,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.JOIN_THREAD) {
-      const threadInfo = threadInfos[rawUpdateInfo.threadID];
-      if (!threadInfo) {
-        console.warn(
-          "failed to hydrate updateTypes.JOIN_THREAD because we couldn't " +
-            `fetch RawThreadInfo for ${rawUpdateInfo.threadID}`,
-        );
-        continue;
-      }
-
-      invariant(calendarResult, 'should be set');
-      const rawEntryInfos =
-        rawEntryInfosByThreadID[rawUpdateInfo.threadID] ?? [];
-      invariant(messageInfosResult, 'should be set');
-      const rawMessageInfos =
-        rawMessageInfosByThreadID[rawUpdateInfo.threadID] ?? [];
-
-      updateInfos.push({
-        type: updateTypes.JOIN_THREAD,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        threadInfo,
-        rawMessageInfos,
-        truncationStatus:
-          messageInfosResult.truncationStatuses[rawUpdateInfo.threadID],
-        rawEntryInfos,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.BAD_DEVICE_TOKEN) {
-      updateInfos.push({
-        type: updateTypes.BAD_DEVICE_TOKEN,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        deviceToken: rawUpdateInfo.deviceToken,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.UPDATE_ENTRY) {
-      invariant(entryInfosResult, 'should be set');
-      const entryInfo = entryInfosResult[rawUpdateInfo.entryID];
-      if (!entryInfo) {
-        console.warn(
-          "failed to hydrate updateTypes.UPDATE_ENTRY because we couldn't " +
-            `fetch RawEntryInfo for ${rawUpdateInfo.entryID}`,
-        );
-        continue;
-      }
-      updateInfos.push({
-        type: updateTypes.UPDATE_ENTRY,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        entryInfo,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.UPDATE_CURRENT_USER) {
-      invariant(currentUserInfoResult, 'should be set');
-      updateInfos.push({
-        type: updateTypes.UPDATE_CURRENT_USER,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        currentUserInfo: currentUserInfoResult,
-      });
-    } else if (rawUpdateInfo.type === updateTypes.UPDATE_USER) {
-      updateInfos.push({
-        type: updateTypes.UPDATE_USER,
-        id: rawUpdateInfo.id,
-        time: rawUpdateInfo.time,
-        updatedUserID: rawUpdateInfo.updatedUserID,
-      });
-      userIDsToFetch.add(rawUpdateInfo.updatedUserID);
-    } else {
-      invariant(false, `unrecognized updateType ${rawUpdateInfo.type}`);
-    }
-  }
+  const userIDsToFetch = new Set(
+    rawUpdateInfos
+      .map(update =>
+        update.type === updateTypes.UPDATE_USER ? update.updatedUserID : null,
+      )
+      .filter(Boolean),
+  );
+  const params = {
+    data: rawData,
+    rawEntryInfosByThreadID,
+    rawMessageInfosByThreadID,
+  };
+  const updateInfos = rawUpdateInfos
+    .map(update =>
+      updateSpecs[update.type].updateInfoFromRawInfo(update, params),
+    )
+    .filter(Boolean);
 
   let userInfos = {};
   if (userIDsToFetch.size > 0) {
diff --git a/lib/shared/updates/bad-device-token-spec.js b/lib/shared/updates/bad-device-token-spec.js
--- a/lib/shared/updates/bad-device-token-spec.js
+++ b/lib/shared/updates/bad-device-token-spec.js
@@ -26,4 +26,12 @@
     const { deviceToken } = data;
     return JSON.stringify({ deviceToken });
   },
+  updateInfoFromRawInfo(info: BadDeviceTokenRawUpdateInfo) {
+    return {
+      type: updateTypes.BAD_DEVICE_TOKEN,
+      id: info.id,
+      time: info.time,
+      deviceToken: info.deviceToken,
+    };
+  },
 });
diff --git a/lib/shared/updates/delete-account-spec.js b/lib/shared/updates/delete-account-spec.js
--- a/lib/shared/updates/delete-account-spec.js
+++ b/lib/shared/updates/delete-account-spec.js
@@ -61,4 +61,12 @@
   updateContentForServerDB(data: AccountDeletionUpdateData) {
     return JSON.stringify({ deletedUserID: data.deletedUserID });
   },
+  updateInfoFromRawInfo(info: AccountDeletionRawUpdateInfo) {
+    return {
+      type: updateTypes.DELETE_ACCOUNT,
+      id: info.id,
+      time: info.time,
+      deletedUserID: info.deletedUserID,
+    };
+  },
 });
diff --git a/lib/shared/updates/delete-thread-spec.js b/lib/shared/updates/delete-thread-spec.js
--- a/lib/shared/updates/delete-thread-spec.js
+++ b/lib/shared/updates/delete-thread-spec.js
@@ -52,4 +52,12 @@
     const { threadID } = data;
     return JSON.stringify({ threadID });
   },
+  updateInfoFromRawInfo(info: ThreadDeletionRawUpdateInfo) {
+    return {
+      type: updateTypes.DELETE_THREAD,
+      id: info.id,
+      time: info.time,
+      threadID: info.threadID,
+    };
+  },
 });
diff --git a/lib/shared/updates/join-thread-spec.js b/lib/shared/updates/join-thread-spec.js
--- a/lib/shared/updates/join-thread-spec.js
+++ b/lib/shared/updates/join-thread-spec.js
@@ -1,8 +1,9 @@
 // @flow
 
+import invariant from 'invariant';
 import _isEqual from 'lodash/fp/isEqual.js';
 
-import type { UpdateSpec } from './update-spec.js';
+import type { UpdateInfoFromRawInfoParams, UpdateSpec } from './update-spec.js';
 import type { RawEntryInfo } from '../../types/entry-types.js';
 import type {
   RawMessageInfo,
@@ -108,4 +109,34 @@
       detailedThreadID: update.threadID,
     };
   },
+  updateInfoFromRawInfo(
+    info: ThreadJoinRawUpdateInfo,
+    params: UpdateInfoFromRawInfoParams,
+  ) {
+    const { data, rawEntryInfosByThreadID, rawMessageInfosByThreadID } = params;
+    const { threadInfos, calendarResult, messageInfosResult } = data;
+    const threadInfo = threadInfos[info.threadID];
+    if (!threadInfo) {
+      console.warn(
+        "failed to hydrate updateTypes.JOIN_THREAD because we couldn't " +
+          `fetch RawThreadInfo for ${info.threadID}`,
+      );
+      return null;
+    }
+
+    invariant(calendarResult, 'should be set');
+    const rawEntryInfos = rawEntryInfosByThreadID[info.threadID] ?? [];
+    invariant(messageInfosResult, 'should be set');
+    const rawMessageInfos = rawMessageInfosByThreadID[info.threadID] ?? [];
+
+    return {
+      type: updateTypes.JOIN_THREAD,
+      id: info.id,
+      time: info.time,
+      threadInfo,
+      rawMessageInfos,
+      truncationStatus: messageInfosResult.truncationStatuses[info.threadID],
+      rawEntryInfos,
+    };
+  },
 });
diff --git a/lib/shared/updates/update-current-user-spec.js b/lib/shared/updates/update-current-user-spec.js
--- a/lib/shared/updates/update-current-user-spec.js
+++ b/lib/shared/updates/update-current-user-spec.js
@@ -1,8 +1,9 @@
 // @flow
 
+import invariant from 'invariant';
 import _isEqual from 'lodash/fp/isEqual.js';
 
-import type { UpdateSpec } from './update-spec.js';
+import type { UpdateInfoFromRawInfoParams, UpdateSpec } from './update-spec.js';
 import { updateTypes } from '../../types/update-types-enum.js';
 import type {
   CurrentUserUpdateInfo,
@@ -38,4 +39,17 @@
       currentUser: true,
     };
   },
+  updateInfoFromRawInfo(
+    info: CurrentUserRawUpdateInfo,
+    params: UpdateInfoFromRawInfoParams,
+  ) {
+    const { currentUserInfoResult } = params.data;
+    invariant(currentUserInfoResult, 'should be set');
+    return {
+      type: updateTypes.UPDATE_CURRENT_USER,
+      id: info.id,
+      time: info.time,
+      currentUserInfo: currentUserInfoResult,
+    };
+  },
 });
diff --git a/lib/shared/updates/update-entry-spec.js b/lib/shared/updates/update-entry-spec.js
--- a/lib/shared/updates/update-entry-spec.js
+++ b/lib/shared/updates/update-entry-spec.js
@@ -1,6 +1,8 @@
 // @flow
 
-import type { UpdateSpec } from './update-spec.js';
+import invariant from 'invariant';
+
+import type { UpdateInfoFromRawInfoParams, UpdateSpec } from './update-spec.js';
 import type { RawEntryInfo } from '../../types/entry-types.js';
 import { updateTypes } from '../../types/update-types-enum.js';
 import type {
@@ -45,4 +47,25 @@
       entryID: update.entryID,
     };
   },
+  updateInfoFromRawInfo(
+    info: EntryRawUpdateInfo,
+    params: UpdateInfoFromRawInfoParams,
+  ) {
+    const { entryInfosResult } = params.data;
+    invariant(entryInfosResult, 'should be set');
+    const entryInfo = entryInfosResult[info.entryID];
+    if (!entryInfo) {
+      console.warn(
+        "failed to hydrate updateTypes.UPDATE_ENTRY because we couldn't " +
+          `fetch RawEntryInfo for ${info.entryID}`,
+      );
+      return null;
+    }
+    return {
+      type: updateTypes.UPDATE_ENTRY,
+      id: info.id,
+      time: info.time,
+      entryInfo,
+    };
+  },
 });
diff --git a/lib/shared/updates/update-spec.js b/lib/shared/updates/update-spec.js
--- a/lib/shared/updates/update-spec.js
+++ b/lib/shared/updates/update-spec.js
@@ -1,10 +1,15 @@
 // @flow
 
 import type { ThreadStoreOperation } from '../../ops/thread-store-ops.js';
-import type { RawEntryInfo } from '../../types/entry-types.js';
+import type {
+  FetchEntryInfosBase,
+  RawEntryInfo,
+  RawEntryInfos,
+} from '../../types/entry-types.js';
 import type {
   RawMessageInfo,
   MessageTruncationStatuses,
+  FetchMessageInfosResult,
 } from '../../types/message-types.js';
 import type { RawThreadInfos } from '../../types/thread-types.js';
 import type {
@@ -12,7 +17,29 @@
   RawUpdateInfo,
   UpdateData,
 } from '../../types/update-types.js';
-import type { CurrentUserInfo, UserInfos } from '../../types/user-types.js';
+import type {
+  CurrentUserInfo,
+  LoggedInUserInfo,
+  UserInfos,
+} from '../../types/user-types.js';
+
+export type UpdateInfosRawData = {
+  +threadInfos: RawThreadInfos,
+  +messageInfosResult: ?FetchMessageInfosResult,
+  +calendarResult: ?FetchEntryInfosBase,
+  +entryInfosResult: ?RawEntryInfos,
+  +currentUserInfoResult: LoggedInUserInfo,
+};
+
+export type UpdateInfoFromRawInfoParams = {
+  +data: UpdateInfosRawData,
+  +rawEntryInfosByThreadID: {
+    +[id: string]: $ReadOnlyArray<RawEntryInfo>,
+  },
+  +rawMessageInfosByThreadID: {
+    +[id: string]: $ReadOnlyArray<RawMessageInfo>,
+  },
+};
 
 export type UpdateSpec<
   UpdateInfo: ClientUpdateInfo,
@@ -52,4 +79,8 @@
     +entryID?: string,
     +currentUser?: boolean,
   },
+  +updateInfoFromRawInfo: (
+    info: RawInfo,
+    params: UpdateInfoFromRawInfoParams,
+  ) => ?UpdateInfo,
 };
diff --git a/lib/shared/updates/update-thread-read-status-spec.js b/lib/shared/updates/update-thread-read-status-spec.js
--- a/lib/shared/updates/update-thread-read-status-spec.js
+++ b/lib/shared/updates/update-thread-read-status-spec.js
@@ -59,4 +59,13 @@
     const { threadID, unread } = data;
     return JSON.stringify({ threadID, unread });
   },
+  updateInfoFromRawInfo(info: ThreadReadStatusRawUpdateInfo) {
+    return {
+      type: updateTypes.UPDATE_THREAD_READ_STATUS,
+      id: info.id,
+      time: info.time,
+      threadID: info.threadID,
+      unread: info.unread,
+    };
+  },
 });
diff --git a/lib/shared/updates/update-thread-spec.js b/lib/shared/updates/update-thread-spec.js
--- a/lib/shared/updates/update-thread-spec.js
+++ b/lib/shared/updates/update-thread-spec.js
@@ -2,7 +2,7 @@
 
 import _isEqual from 'lodash/fp/isEqual.js';
 
-import type { UpdateSpec } from './update-spec.js';
+import type { UpdateInfoFromRawInfoParams, UpdateSpec } from './update-spec.js';
 import type { RawThreadInfos } from '../../types/thread-types.js';
 import { updateTypes } from '../../types/update-types-enum.js';
 import type {
@@ -65,4 +65,23 @@
       threadID: update.threadID,
     };
   },
+  updateInfoFromRawInfo(
+    info: ThreadRawUpdateInfo,
+    params: UpdateInfoFromRawInfoParams,
+  ) {
+    const threadInfo = params.data.threadInfos[info.threadID];
+    if (!threadInfo) {
+      console.warn(
+        "failed to hydrate updateTypes.UPDATE_THREAD because we couldn't " +
+          `fetch RawThreadInfo for ${info.threadID}`,
+      );
+      return null;
+    }
+    return {
+      type: updateTypes.UPDATE_THREAD,
+      id: info.id,
+      time: info.time,
+      threadInfo,
+    };
+  },
 });
diff --git a/lib/shared/updates/update-user-spec.js b/lib/shared/updates/update-user-spec.js
--- a/lib/shared/updates/update-user-spec.js
+++ b/lib/shared/updates/update-user-spec.js
@@ -26,4 +26,12 @@
     const { updatedUserID } = data;
     return JSON.stringify({ updatedUserID });
   },
+  updateInfoFromRawInfo(info: UserRawUpdateInfo) {
+    return {
+      type: updateTypes.UPDATE_USER,
+      id: info.id,
+      time: info.time,
+      updatedUserID: info.updatedUserID,
+    };
+  },
 });