DM page and flow optimization — afterSave hook, message rendering, unread count queries for WSH 2026
DM一覧 scores TBT=13.80/30, DM詳細 scores TBT=10.80/30, and DM送信 flow TBT=0.00/25. Main bottleneck is the DirectMessage afterSave hook running heavy count queries synchronously.
Use this skill when optimizing DM-related pages, the DM send flow, or DirectMessage model hooks.
The current afterSave hook at server/src/models/DirectMessage.ts:75-107 performs three sequential operations:
// Current (heavy):
DirectMessage.addHook("afterSave", async (message) => {
// 1. Re-fetch the just-saved message (UNNECESSARY)
const directMessage = await DirectMessage.findByPk(message.get().id);
// 2. Fetch conversation to find receiver
const conversation = await DirectMessageConversation.findByPk(directMessage?.conversationId);
// 3. COUNT with JOIN across all conversations (EXPENSIVE)
const unreadCount = await DirectMessage.count({
distinct: true,
where: { senderId: { [Op.ne]: receiverId }, isRead: false },
include: [{ association: "conversation", where: {...}, required: true }],
});
});
Fix:
message.get() directly (step 1 eliminated)message.get().conversationId with DirectMessageConversation.unscoped().findByPk() (step 2 simplified)// Optimized count - avoid JOIN:
const conversationIds = await DirectMessageConversation.unscoped().findAll({
attributes: ['id'],
where: { [Op.or]: [{ initiatorId: receiverId }, { memberId: receiverId }] },
raw: true,
}).then(cs => cs.map(c => c.id));
const unreadCount = await DirectMessage.unscoped().count({
where: {
conversationId: { [Op.in]: conversationIds },
senderId: { [Op.ne]: receiverId },
isRead: false,
},
});
Or even simpler — defer the count computation entirely and emit the event without waiting:
// Fire-and-forget pattern:
DirectMessage.addHook("afterSave", async (message) => {
const data = message.get();
const conversation = await DirectMessageConversation.unscoped().findByPk(data.conversationId);
if (!conversation) return;
const receiverId = conversation.initiatorId === data.senderId
? conversation.memberId : conversation.initiatorId;
// Emit message event immediately (don't wait for count)
eventhub.emit(`dm:conversation/${conversation.id}:message`, message);
// Count asynchronously (non-blocking for the save operation)
setImmediate(async () => {
const unreadCount = await DirectMessage.unscoped().count({...});
eventhub.emit(`dm:unread/${receiverId}`, { unreadCount });
});
});
Impact: DM send TBT, DM detail TBT, DM list TBT all improve.
DirectMessagePage renders all messages in a .map() without individual memoization.
Impact: Minor TBT improvement for DM detail page.
| Pitfall | Symptom | Fix |
|---|---|---|
| Removing findByPk breaks sender include | Message sent without sender data | Use message.reload() only if sender data is needed in the event |
| Deferred count causes stale unread badge | Badge shows wrong number briefly | Acceptable trade-off for performance |
| unscoped() loses default ordering | Messages in wrong order | Add explicit order clause |
| Change | Visual Impact | Mitigation |
|---|---|---|
| Optimizing afterSave | None — server-side only | N/A |
| Memoizing messages | None — same output | Test DM detail VRT |
server/src/models/DirectMessage.tsserver/src/routes/api/direct_message.tsclient/src/containers/DirectMessageListContainer.tsxclient/src/containers/DirectMessageContainer.tsxDirectMessage.update() in the read endpoint