The Problem: Pretty But Useless
Three days ago, we shipped Live Universe Events—real-time streaming of EVE Frontier blockchain activity via Cloudflare Durable Objects and WebSockets. Smart gate links, tribe formations, structure deployments, and fuel deliveries scrolling across the bottom of the map in a satisfying ticker. Visual halos and flashes highlighting where the action was happening.
It looked great. Users loved the aesthetic. But there was a fundamental problem: it was completely useless.
Sure, there was a History panel where you could scroll through events. But who's realistically going to click through 500 entries one by one, trying to find the one interesting gate link that happened while they were AFK?
The ticker was a visualisation of activity, not an intelligence tool. And that wasn't good enough.
The 6am Epiphany
Sometimes the best features come from personal frustration. Woke up at 6am—two hours before I needed to—because it was bugging me. The architecture was solid (Durable Objects, WebSocket hibernation, event filtering), but the user experience had a critical gap: no persistence.
Two questions crystallized the solution:
- What if events survived a browser refresh?
- What if you could replay what you missed—at any speed?
90 minutes later, both were in production.
Solution 1: IndexedDB Persistence
Browser localStorage has a ~5MB limit and is synchronous (blocks the main thread). For potentially thousands of events with full metadata, we needed something more capable.
IndexedDB is the browser's native NoSQL database—asynchronous, transactional, and can handle hundreds of megabytes. Perfect for event storage.
The Lazy Loading Challenge
The naive approach would be: wait for IndexedDB to load, then show the UI. But that creates a noticeable delay on page load, and users might think something's broken.
Instead, we implemented lazy loading with seamless merging:
- Page loads instantly—WebSocket connects, events start arriving
- IndexedDB loads in background—no blocking, no loading indicators
- Events merge seamlessly—when DB finishes loading, historical events merge with newly-arrived events
- User never notices—history just "appears" to grow as you use the page
// Simplified lazy loading pattern
useEffect(() => {
// Start SSE immediately - don't wait for IndexedDB
connectToEventStream();
// Load history in background
loadAllEvents().then(storedEvents => {
// Merge with any events that arrived while loading
setEventHistory(prev => mergeAndDedupe(prev, storedEvents));
});
}, []);
24-Hour Rolling Window
We considered various retention strategies:
| Strategy | Pros | Cons |
|---|---|---|
| Fixed count (500 per type) | Predictable memory | Loses temporal context |
| 7-day window | Long history | Potentially huge storage |
| 24-hour window | Relevant context, reasonable size | — |
24 hours hit the sweet spot. At EVE Frontier's typical activity rate (~80,000 events/day), that's approximately 48MB—well under browser quotas, and enough to see "what happened overnight" without storing ancient history nobody cares about.
(~600 bytes × 80,000 events)
Automatic Cleanup
Events older than 24 hours get pruned automatically:
- On page load—clean up stale events before loading
- Hourly while tab is open—prevents unbounded growth if someone leaves it running
Solution 2: Time-Travel Replay
Persistence solved the "events disappear on refresh" problem. But reading through a list of 500 events is still tedious. What if you could watch what happened?
Multi-Speed Playback
Real-time replay would take hours. Nobody has time for that. So we built speed controls:
At 50×, an hour of activity plays back in ~72 seconds. At 500×, it's under 8 seconds. "Max" fires events as fast as the browser can render them.
Time-Proportional Playback
Here's the key insight: events aren't evenly distributed. There are bursts of activity (someone links 10 gates in quick succession) and quiet periods (3am downtime). If we played events at fixed intervals, we'd lose that temporal texture.
Instead, replay preserves the relative timing:
// Time-proportional playback
const realTimeGapMs = nextEvent.timestamp - currentEvent.timestamp;
const scaledDelayMs = Math.min(5000, Math.max(10, realTimeGapMs / speed));
await sleep(scaledDelayMs);
A burst of 10 events that happened in 2 seconds still feels like a burst during replay. A 30-minute quiet period compresses to seconds but still feels like a pause. The rhythm of activity is preserved.
Visual Integration
During replay, events trigger the same visual effects as live events:
- Halos—glowing rings appear on affected star systems
- Flashes—brief brightness bursts draw attention
- Ticker—events scroll across the bottom
You can literally watch the EVE Frontier universe evolve—see gate networks form, structures deploy, tribes grow—in accelerated time.
Implementation Details
IndexedDB Schema
The database structure is intentionally simple:
// Database: ef_event_history
// Store: events
{
id: auto-increment,
eventType: string, // 'gate_linked', 'structure_online', etc.
timestamp: number, // Unix ms - indexed for time-based queries
text: string, // Formatted display text
systemId: number, // For clickable system links
systemName: string, // Resolved name
event: { // Raw event data for replay
type, system_id, data, timestamp
}
}
Two indexes enable efficient queries:
timestamp—for time-range queries and cleanupeventType—for filtered history views (future use)
Graceful Degradation
IndexedDB isn't available in all contexts (private browsing, some mobile browsers, storage pressure). The system handles this gracefully:
export async function initEventHistoryDB(): Promise<IDBDatabase | null> {
if (!window.indexedDB) {
console.warn('[eventHistoryDB] Not available, using memory-only');
return null;
}
// ... initialization
}
If IndexedDB fails, events still work—they just don't persist across sessions. No errors, no broken UI, just reduced functionality.
Event Counter Consistency
A subtle UX issue emerged during testing: the event counter next to "LIVE" showed events received this session, but the History panel showed all persisted events. After a refresh, these numbers would diverge confusingly.
The fix was simple but important: calculate the counter from total history rather than WebSocket count:
// Before: only counts this session's WebSocket events
const { eventCount } = useUniverseEvents({ ... });
// After: counts all events in history (persisted + live)
const totalEventCount = useMemo(() => {
let count = 0;
eventHistory.forEach(items => count += items.length);
return count;
}, [eventHistory]);
Now the counter reflects reality: "6 events available" means 6 events you can actually see and replay.
The User Experience
Here's what the optimized flow looks like:
- First visit—events arrive live, build up in history, persist to IndexedDB in background
- Leave and return—page loads instantly, WebSocket reconnects, IndexedDB history merges in
- Check History—see all events from the last 24 hours, not just current session
- Click Replay—watch events play back at your chosen speed, halos lighting up the map
- Close laptop overnight—come back next day, events from yesterday are still there
Performance Characteristics
| Metric | Value |
|---|---|
| Page load impact | None (lazy loading) |
| IndexedDB load time | ~50-200ms for full history |
| Event save latency | <5ms (async, non-blocking) |
| Storage per event | ~600 bytes |
| Max storage (24h) | ~48MB |
| Replay overhead | Negligible (reuses existing render path) |
What's Next
With persistence and replay in place, several enhancements become possible:
- Filtered replay—replay only gate events, or only activity in your region
- Time scrubbing—drag a slider to jump to specific times
- Export/import—save interesting periods to share with corpmates
- Activity heatmaps—aggregate event density to visualise hot zones
The foundation is now solid. Events persist, replay works, and the architecture can support whatever intelligence features make sense next.
Lessons Learned
A few takeaways from this 90-minute sprint:
- Pretty isn't enough—if users can't act on information, it's decoration
- Lazy loading is underrated—async DB loading with seamless merging feels magical
- Preserve temporal texture—time-proportional replay is more informative than fixed-speed
- Graceful degradation—always have a fallback when browser APIs might fail
- Fix UX inconsistencies immediately—mismatched counters erode trust
Sometimes the best coding sessions happen at 6am when you can't sleep because something's bugging you. Now the EVE Frontier map doesn't just show you what's happening—it shows you what you missed.