# Real-time fan-out uses channel membership cache, not per-message permission reads

- **Pattern:** `ab-000749` (`community-realtime.presence.fanout-cache-not-per-message`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000749
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000749)

## Why it matters

Per-message database queries to determine channel membership collapse throughput to whatever the database can handle divided by messages per second. At modest scale — 50 messages per second across 10 channels — that is 50 synchronous permission queries in the hot path, turning a real-time feature into a slow polling system. ISO 25010 performance-efficiency and scalability require that read-hot paths use caching rather than per-operation round trips to persistent storage.

## Severity rationale

Medium because per-message permission queries add a synchronous database round-trip for every broadcast, collapsing throughput at moderate message rates.

## Remediation

Use the WebSocket framework's built-in room tracking — or an explicit in-memory/Redis membership map — as the fan-out source. Channel membership should be updated on subscribe/unsubscribe, not queried on send.

```ts
// Subscribe: update the membership cache once
socket.on('subscribe', async (channel: string) => {
  if (await checkChannelAccess(socket.userId, channel)) {
    socket.join(channel); // Socket.IO maintains membership in-process
  }
});

// Send: fan out from the cache — zero DB queries
socket.on('send_message', (data: { channel: string; content: string }) => {
  io.to(data.channel).emit('message', {
    id: nanoid(),
    content: data.content,
    userId: socket.userId,
    timestamp: Date.now(),
  });
});

// Avoid: querying DB on every send
// const members = await db.select().from(channelMembers).where(...);
```

## Detection

- **ID:** `fanout-cache-not-per-message`
- **Severity:** `medium`
- **What to look for:** Count all message broadcast paths. For each, classify whether the system uses a membership cache or performs per-message database queries. Enumerate the caching mechanisms: framework rooms, Redis sets, in-memory maps.
- **Pass criteria:** The system maintains at least 1 channel membership cache (or uses the WebSocket framework's built-in room/namespace tracking). Messages are fanned out to cached subscribers with 0 per-message permission queries.
- **Fail criteria:** For every message, the system queries a database to determine which users have permission to receive it.
- **Skip (N/A) when:** Never — per-message permission checks would not scale.
- **Detail on fail:** `"Every message triggers a database query to determine channel subscribers. This will not scale beyond tens of messages per second."`
- **Remediation:** Use membership caching for fan-out:

  ```ts
  // Use Socket.IO's built-in room management
  socket.on('subscribe', (channel) => {
    socket.join(channel); // Cached by Socket.IO
  });

  // Broadcast is fast because Socket.IO has the membership
  socket.on('send_message', (data) => {
    io.to(data.channel).emit('message', data);
  });

  // Avoid this pattern:
  socket.on('send_message', async (data) => {
    const members = await db.query('SELECT userId FROM channel_members WHERE channel = ?', [data.channel]);
    for (const member of members) {
      io.to(member.userId).emit('message', data); // Slow!
    }
  });
  ```

---

## External references

- iso-25010 performance-efficiency.time-behaviour

Taxons: performance, cost-efficiency

HTML version: https://auditbuffet.com/patterns/ab-000749
