Building Keito’s Time Tracking Engine
TL;DR — Keito’s engine uses event sourcing for full auditability, WebSocket-driven real-time updates under 100ms, and a projection layer that materialises views on the fly. We handle 10k+ concurrent timers with a single-digit millisecond p99.
When we set out to build Keito, we knew the time tracking engine had to be fast, reliable, and capable of handling thousands of concurrent users without breaking a sweat. In this post, we walk through the key architectural decisions that power the core of Keito.
Event Sourcing at the Core
Rather than storing mutable state, every time entry in Keito is modelled as a stream of immutable events. This gives us a complete audit trail and makes it trivial to reconstruct state at any point in time.
Here’s a simplified example of how we model a time entry lifecycle:
interface TimeEntryEvent {
id: string;
type: 'TIMER_STARTED' | 'TIMER_STOPPED' | 'ENTRY_EDITED' | 'ENTRY_APPROVED';
timestamp: Date;
userId: string;
payload: Record<string, unknown>;
}
function applyEvent(state: TimeEntry, event: TimeEntryEvent): TimeEntry {
switch (event.type) {
case 'TIMER_STARTED':
return { ...state, status: 'running', startedAt: event.timestamp };
case 'TIMER_STOPPED':
return {
...state,
status: 'stopped',
duration: calcDuration(state.startedAt, event.timestamp),
};
case 'ENTRY_APPROVED':
return { ...state, status: 'approved', approvedBy: event.payload.managerId };
default:
return state;
}
}
Each event is persisted to an append-only store. The current state of any time entry is reconstructed by replaying its events — a pattern sometimes called “left fold over history”.
Why event sourcing? For a time tracking product, auditability isn’t optional. Clients need to trust that the hours on their invoice are accurate. Event sourcing gives us a tamper-evident log of every change, by whom, and when.
The Projection Layer
Raw event streams are powerful but slow to query. We use a projection layer that materialises read-optimised views in real time:
| Projection | Purpose | Update Frequency |
|---|---|---|
user_timers | Active timers per user | On every start/stop event |
weekly_summary | Hours by project/day | Every 5 seconds (batched) |
approval_queue | Pending entries for managers | On submit/approve events |
invoice_ready | Approved hours grouped by client | On approval events |
Projections are rebuilt from the event log on deploy, so we can safely add new views without migrating data.
Real-Time Updates
Using WebSockets and our background in Apache Kafka, we built a notification layer that pushes updates to connected clients in under 100ms. When a team member starts a timer, their manager sees it instantly.
The flow is straightforward:
- User starts a timer via the API
- A
TIMER_STARTEDevent is persisted - The projection layer updates
user_timers - A WebSocket broadcast notifies all connected team members
- The UI updates without a page refresh
// Server-side broadcast on event persistence
eventStore.on('persisted', (event: TimeEntryEvent) => {
const teamId = getTeamId(event.userId);
wsServer.broadcast(teamId, {
type: 'TIMER_UPDATE',
userId: event.userId,
status: event.type === 'TIMER_STARTED' ? 'running' : 'stopped',
timestamp: event.timestamp,
});
});
Performance at Scale
We benchmark continuously. Here are our current numbers on a production-equivalent environment:
| Metric | Value |
|---|---|
| Concurrent active timers | 10,000+ |
| Event write latency (p99) | 4ms |
| Projection update latency | 12ms |
| WebSocket delivery (p99) | 87ms |
| Event replay (full rebuild) | ~3 minutes for 10M events |
What’s Next
We’re continuing to invest in the engine’s performance and plan to share more benchmarks in upcoming posts. Next up: how we handle offline-first time tracking on mobile and reconcile events when connectivity returns.