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.
// ❌ 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.
// ✅ 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:
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:
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
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
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 liveReal-World Example: Notification System
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 instantlyConfiguration: Buffer Size
The replayMemory setting determines how many events to keep per event type:
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 eventMemory calculation:
// 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 = 100KBRecommended 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:
// 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
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 replayNote: Replayed events always execute at the priority of the subscription, not the original emission priority.
Replay + Resilience
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 failReplay + Wildcards
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:* eventsEdge Case: Multiple Replays
Each time you subscribe with replay: true, the handler receives all buffered events:
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: 3Both handlers receive the full history independently.
Edge Case: Replay Without Buffer
If replayMemory is 0 or not set, replay does nothing:
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 existsEdge Case: Circular Buffer Overflow
When the buffer is full, oldest events are discarded:
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:
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
replayMemoryas 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
| Approach | Pros | Cons |
|---|---|---|
| Manual State Variables | Simple, explicit | Easy to forget updates, gets stale |
| Global Stores (Redux) | Centralized, time-travel | Heavy, separate system to learn |
| Nexus Cursors | Sync access, always current | Only stores latest value |
| Nexus Replay | Full history, automatic | Uses 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:
- Cursors & State - Synchronous access to latest value
- Priority Lanes - Control execution timing
- Wildcard Patterns - Replay works with wildcards
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: trueonly 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.
