Skip to content

Event Replay & History

The DVR Analogy

Your cable TV box has a buffer that records the last 30 minutes of live TV. If you arrive home late, you can "rewind" and watch from the beginning - you didn't miss anything.

Traditional event buses are "live-only broadcasts": If you subscribe after an event has been emitted, you miss it forever.

typescript
// ❌ Traditional Event Bus: Late subscribers miss data
bus.emit('config:loaded', { theme: 'dark' });

// 5 seconds later, component mounts
setTimeout(() => {
  bus.on('config:loaded', (config) => {
    console.log(config); // NEVER CALLED - event already passed
  });
}, 5000);

Nexus has a replay buffer: Late subscribers can "rewind" and catch up.

typescript
// ✅ Nexus: Late subscribers get historical events
const bus = new Nexus({ replayMemory: 50 });

bus.emit('config:loaded', { theme: 'dark' });

setTimeout(() => {
  bus.on('config:loaded', (config) => {
    console.log(config); // { theme: 'dark' } - Got it!
  }, { replay: true }); // 👈 Enable replay
}, 5000);

The Problem: Asynchronous Component Lifecycles

Modern web applications load components asynchronously:

  • Code splitting: Components load on-demand
  • Lazy mounting: UI elements render when scrolled into view
  • Network delays: API responses arrive at unpredictable times
  • Race conditions: Order of initialization varies

Example scenario:

t=0ms   → Config loads from server
t=10ms  → Config emitted: { apiKey: 'abc123', region: 'us-west' }
t=50ms  → Dashboard component mounts (needs config)
t=100ms → Settings panel mounts (needs config)

Without replay, Dashboard and Settings would never receive the config that was emitted before they existed.

The Solution: Replay Memory

Nexus stores a circular buffer of past events that subscribers can replay on demand:

typescript
const bus = new Nexus({
  replayMemory: 50  // Keep last 50 events per event type
});

Key behaviors:

  • Each event type has its own buffer (user:login, config:loaded, etc.)
  • Buffer size is per event type, not global
  • Oldest events are automatically removed when buffer is full
  • Replay is opt-in at subscription time

How Replay Memory Works

Behind the scenes:

typescript
class Nexus {
  private history = new Map<string, any[]>();
  
  emit(event: string, payload: any) {
    // ... other logic
    
    // Add to history buffer
    if (this.config.replayMemory > 0) {
      if (!this.history.has(event)) {
        this.history.set(event, []);
      }
      
      const buffer = this.history.get(event)!;
      buffer.push(payload);
      
      // Circular buffer: Remove oldest if full
      if (buffer.length > this.config.replayMemory) {
        buffer.shift(); // Remove first (oldest)
      }
    }
  }
  
  on(event: string, fn: Listener, options: SubscribeOptions) {
    // ... subscription logic
    
    // Replay history if requested
    if (options.replay && this.config.replayMemory > 0) {
      const buffer = this.history.get(event) || [];
      buffer.forEach(payload => fn(payload));
    }
    
    // Then listen for future events
    return subscription;
  }
}

Basic Example: Application Configuration

typescript
import { Nexus } from '@caeligo/nexus-orchestrator';

const bus = new Nexus({ replayMemory: 10 });

// App initialization: Load config early
async function initializeApp() {
  const config = await fetch('/api/config').then(r => r.json());
  
  bus.emit('config:loaded', {
    apiUrl: config.apiUrl,
    theme: config.theme,
    features: config.enabledFeatures
  });
}

initializeApp();

// Later, when Dashboard component mounts (could be 5 seconds later)
class Dashboard {
  constructor() {
    bus.on('config:loaded', (config) => {
      // Receives config even though it was emitted before this component existed
      this.apiClient = new APIClient(config.apiUrl);
      this.applyTheme(config.theme);
    }, { replay: true }); // 👈 Get historical config
  }
}

// Even later, Settings panel mounts
class SettingsPanel {
  constructor() {
    bus.on('config:loaded', (config) => {
      // Also receives the same config
      this.renderFeatureToggles(config.features);
    }, { replay: true });
  }
}

Real-World Example: Chat Application

typescript
import { Nexus } from '@caeligo/nexus-orchestrator';

interface ChatMessage {
  id: string;
  user: string;
  text: string;
  timestamp: number;
}

const bus = new Nexus({ replayMemory: 100 }); // Keep last 100 messages

// Simulate receiving messages from WebSocket
const ws = new WebSocket('wss://chat.example.com');

ws.onmessage = (event) => {
  const message: ChatMessage = JSON.parse(event.data);
  bus.emit('chat:message', message);
};

// User scrolls down and loads the chat UI component
class ChatWindow {
  constructor() {
    // Get last 100 messages immediately (from replay buffer)
    bus.on('chat:message', (message) => {
      this.appendMessage(message);
    }, { replay: true });
    
    // Then continue receiving new messages
  }
  
  appendMessage(message: ChatMessage) {
    const messageEl = document.createElement('div');
    messageEl.textContent = `${message.user}: ${message.text}`;
    document.getElementById('chat-messages')?.appendChild(messageEl);
  }
}

// User sees the last 100 messages instantly, then new messages arrive live

Real-World Example: Notification System

typescript
import { Nexus } from '@caeligo/nexus-orchestrator';

interface Notification {
  id: string;
  type: 'info' | 'warning' | 'error';
  message: string;
  timestamp: number;
}

const bus = new Nexus({ replayMemory: 20 }); // Keep last 20 notifications

// Background service emits notifications
class BackgroundSync {
  async syncData() {
    try {
      await fetch('/api/sync');
      bus.emit('notification:system', {
        id: crypto.randomUUID(),
        type: 'info',
        message: 'Sync completed',
        timestamp: Date.now()
      });
    } catch (error) {
      bus.emit('notification:system', {
        id: crypto.randomUUID(),
        type: 'error',
        message: 'Sync failed',
        timestamp: Date.now()
      });
    }
  }
}

// Notification center UI (lazy-loaded when user clicks bell icon)
class NotificationCenter {
  constructor() {
    // Show all recent notifications from history
    bus.on('notification:system', (notification) => {
      this.addNotificationToList(notification);
    }, { replay: true });
  }
  
  addNotificationToList(notification: Notification) {
    const notifEl = document.createElement('div');
    notifEl.className = `notification notification-${notification.type}`;
    notifEl.textContent = notification.message;
    document.getElementById('notification-list')?.appendChild(notifEl);
  }
}

// User clicks bell icon 5 minutes after app start
// They see all notifications from the last 5 minutes instantly

Configuration: Buffer Size

The replayMemory setting determines how many events to keep per event type:

typescript
const bus = new Nexus({
  replayMemory: 50  // Keep 50 events per event type
});

// Each event type has its own buffer
bus.emit('user:login', { user: 'alice' });    // Buffer: user:login = [event1]
bus.emit('user:login', { user: 'bob' });      // Buffer: user:login = [event1, event2]
bus.emit('api:fetch', { url: '/data' });      // Buffer: api:fetch = [event1]

// Separate buffers:
// - 'user:login': 2 events
// - 'api:fetch': 1 event

Memory calculation:

typescript
// Maximum memory usage estimation:
// MemoryUsage ≈ (Number of Event Types) × (replayMemory) × (Average Payload Size)

// Example:
// - 10 event types
// - replayMemory: 50
// - Average payload: 200 bytes
// 
// Memory = 10 × 50 × 200 = 100,000 bytes = 100KB

Recommended values:

  • Small apps: replayMemory: 10
  • Medium apps: replayMemory: 50
  • Large apps: replayMemory: 100
  • Real-time chat: replayMemory: 200

Subscription Options: Enabling Replay

Replay is opt-in at subscription time:

typescript
// Option 1: Object syntax (recommended)
bus.on('event:name', handler, {
  replay: true
});

// Option 2: Boolean shorthand (legacy)
bus.on('event:name', handler, true);

Why opt-in? Not all subscribers need historical data. Making it explicit:

  • Improves performance (no unnecessary replays)
  • Makes intent clear in code
  • Prevents unexpected behavior

Combining Replay with Other Features

Replay + Priority

typescript
const bus = new Nexus({ replayMemory: 50 });

bus.emit('data:loaded', { rows: 1000 }, { priority: 'high' });

// Later
bus.on('data:loaded', (data) => {
  processData(data);
}, { replay: true });

// Replay executes at NORMAL priority (default)
// Original priority (high) is not preserved in replay

Note: Replayed events always execute at the priority of the subscription, not the original emission priority.

Replay + Resilience

typescript
const bus = new Nexus({ replayMemory: 20 });

bus.emit('api:response', { status: 500, error: 'Server error' });

// Later
bus.on('api:response', (response) => {
  if (response.status !== 200) throw new Error('API failed');
  processData(response);
}, {
  replay: true,
  attempts: 3,        // Retry on failure
  backoff: 1000
});

// Replayed events trigger retry logic if they fail

Replay + Wildcards

typescript
const bus = new Nexus({ replayMemory: 50 });

// Emit multiple related events
bus.emit('user:login', { user: 'alice' });
bus.emit('user:logout', { user: 'bob' });
bus.emit('user:update', { user: 'charlie' });

// Subscribe with wildcard + replay
bus.on('user:*', (payload) => {
  console.log('User event:', payload);
}, { replay: true });

// Replays: user:login, user:logout, user:update
// Then listens for future user:* events

Edge Case: Multiple Replays

Each time you subscribe with replay: true, the handler receives all buffered events:

typescript
const bus = new Nexus({ replayMemory: 10 });

bus.emit('data:update', { value: 1 });
bus.emit('data:update', { value: 2 });
bus.emit('data:update', { value: 3 });

// First subscription
bus.on('data:update', (data) => {
  console.log('Handler A:', data.value);
}, { replay: true });
// Logs: Handler A: 1
// Logs: Handler A: 2
// Logs: Handler A: 3

// Second subscription
bus.on('data:update', (data) => {
  console.log('Handler B:', data.value);
}, { replay: true });
// Logs: Handler B: 1
// Logs: Handler B: 2
// Logs: Handler B: 3

Both handlers receive the full history independently.

Edge Case: Replay Without Buffer

If replayMemory is 0 or not set, replay does nothing:

typescript
const bus = new Nexus(); // replayMemory defaults to 0

bus.emit('event', { data: 'test' });

bus.on('event', handler, { replay: true });
// Handler is NOT called - no buffer exists

Edge Case: Circular Buffer Overflow

When the buffer is full, oldest events are discarded:

typescript
const bus = new Nexus({ replayMemory: 3 });

bus.emit('event', { id: 1 });
bus.emit('event', { id: 2 });
bus.emit('event', { id: 3 });
// Buffer: [1, 2, 3]

bus.emit('event', { id: 4 });
// Buffer: [2, 3, 4] - ID 1 was removed

bus.on('event', (data) => {
  console.log(data.id);
}, { replay: true });
// Logs: 2, 3, 4 (ID 1 is gone)

Performance Considerations

Replay has performance implications:

typescript
const bus = new Nexus({ replayMemory: 1000 }); // Large buffer

// Emit 1000 events
for (let i = 0; i < 1000; i++) {
  bus.emit('data:stream', { value: i });
}

// Subscribe with replay
bus.on('data:stream', (data) => {
  // This handler executes 1000 times immediately
  expensiveOperation(data);
}, { replay: true });

Best practices:

  • Keep replayMemory as small as needed
  • Use replay only for events that genuinely need history
  • Consider debouncing or throttling replayed events
  • Profile memory usage in production

Comparison with Alternatives

ApproachProsCons
Manual State VariablesSimple, explicitEasy to forget updates, gets stale
Global Stores (Redux)Centralized, time-travelHeavy, separate system to learn
Nexus CursorsSync access, always currentOnly stores latest value
Nexus ReplayFull history, automaticUses memory, opt-in required

When to use each:

  • Cursors: Need current value only → Cursors & State
  • Replay: Need full history (last N events)
  • Manual State: Very simple cases with 1-2 variables
  • Redux: Complex state with time-travel debugging needs

When to Use Replay

Use replay when:

  • ✅ Components load asynchronously (code splitting, lazy loading)
  • ✅ Initialization order is unpredictable
  • ✅ You need to show historical data (notifications, messages, logs)
  • ✅ Late subscribers need to catch up automatically

Don't use replay when:

  • ❌ You only need the current value (use Cursors instead)
  • ❌ Events are transient and shouldn't persist (animations, mouse moves)
  • ❌ Memory is constrained (mobile, low-end devices)
  • ❌ Events are huge (large file uploads, video streams)

Next Steps

Now that you understand event replay, explore related concepts:

Replay Checklist

When using replay memory, ask:

  • [ ] Do I really need history, or just the current value? (Consider Cursors)
  • [ ] What's the appropriate buffer size? (Balance memory vs coverage)
  • [ ] Are my payloads small? (Large objects × large buffer = high memory)
  • [ ] Am I using replay: true only where needed?
  • [ ] Have I tested what happens when the buffer overflows?
  • [ ] Will replaying N events block the UI? (Consider debouncing)

Replay is powerful but should be sized appropriately for your use case.

Released under the MIT License.