Building Keito's Time Tracking Engine

Keito Engineering
15 February 2026 · 3 min read

A deep dive into the architecture behind Keito's real-time time tracking engine, from event sourcing to sub-second updates.

Engineering

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:

ProjectionPurposeUpdate Frequency
user_timersActive timers per userOn every start/stop event
weekly_summaryHours by project/dayEvery 5 seconds (batched)
approval_queuePending entries for managersOn submit/approve events
invoice_readyApproved hours grouped by clientOn 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:

  1. User starts a timer via the API
  2. A TIMER_STARTED event is persisted
  3. The projection layer updates user_timers
  4. A WebSocket broadcast notifies all connected team members
  5. 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:

MetricValue
Concurrent active timers10,000+
Event write latency (p99)4ms
Projection update latency12ms
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.