Engineering Case Study
LiveOps
Real-Time Event Processing & Workflow Platform
A modular-monolith event-driven platform: idempotent ingest, a transactional outbox, an event bus with independent consumer groups, a workflow engine running real multi-step sagas with orchestrated compensation, CQRS projections, real distributed tracing, circuit breakers, dead-letter replay, backpressure, per-visitor sandbox tenants, and — as its own measured experiment — a complete second event-bus transport (Kafka) built and load-tested against the first. Every architectural choice was made against a real alternative, most were tested by actually breaking the system, and two of them were measured and found to help less than expected, kept in the record instead of edited out.
Overview
What LiveOps is
Most portfolio projects that reach for "event-driven architecture" add Kafka on day one and call it done. LiveOps started from the opposite instinct: build the smallest complete version of the real problem first — durable ingest, reliable async processing, a saga that can actually fail and recover — on Postgres alone, then let a load test decide whether a broker was actually the bottleneck before adding one. It wasn't. A second phase then built the Kafka transport anyway, specifically to measure that answer instead of asserting it — and found three unrelated bugs, including one in the load-testing tool itself, before the comparison could be trusted.
Both planned phases complete and verified against a real running stack, not asserted: the live process was killed mid-batch, mid-workflow, and pre-dispatch to prove crash recovery; a real saga was forced through full backward compensation; two separate load tests found and fixed four production-shaped bottlenecks across two different event-bus transports — including a bug in the load-testing tool itself, found while trying to explain a number that didn't add up.
Architecture
How an event moves through the system
- 1POST /api/v1/events → tenant-authenticated, schema-validated, idempotent insert
- 2Event row + outbox row commit together — the transactional outbox
- 3OutboxPublisher → claims pending rows (FOR UPDATE SKIP LOCKED), marks dispatched
- 43 independent consumer groups poll the log at their own pace, own checkpoint each
- 5WorkflowEngine → drives a real saga, persists every step transition, compensates on exhausted retries
- 6ProjectionWorker → feeds CQRS read tables the dashboard queries, live over SSE
Why it's built this way
One lease-based recovery pattern (a locked_until column, not a held transaction or in-memory registry) is used identically by the outbox publisher, the event bus, and the workflow engine. A restarted process needs no separate recovery scan — it runs the exact same claim query it always runs, and an expired lease looks identical to never-claimed work. Verified by actually killing the live process, four separate times, at four different points in the pipeline.
Verified, Not Assumed
Engineering Highlights
- Transactional outbox, not a dual write: the business row and its outbox row commit in the same transaction, so a crash between 'write event' and 'publish' is impossible by construction, verified by killing the live process mid-batch.
- One lease-based recovery pattern (locked_until, not a held transaction or in-memory registry), used identically by the outbox publisher, the event bus, and the workflow engine — a restart needs no separate recovery scan, it just reclaims whatever a dead lease left behind.
- A real saga (order-fulfillment, 5 steps) with orchestrated compensation: forcing a step to exhaust its retries triggers backward compensation in exact reverse order, correctly skipping any step that never completed forward — verified against the persisted step_executions timeline, not just the final status.
- Real distributed tracing across every async boundary: trace context is persisted as data on the row, not carried in ambient context, so one trace ID spans the HTTP request, the outbox, two consumer groups, and all five workflow steps — verified against a real Jaeger trace, not asserted.
- Circuit breakers on both external-shaped saga steps: forcing 3 consecutive payment failures actually opened the breaker, and the next call failed fast instead of retrying against a dependency already known to be down.
- A second, complete event-bus transport (Kafka, via Redpanda) built behind the exact same interface the Postgres transport implements — swapped in with a single environment variable and zero changes to any consumer.
- Dead-letter replay, scoped to what's actually safely replayable: a real historic dead letter from an old bug was replayed and its long-orphaned event finally spawned the workflow it was always supposed to trigger.
- Per-visitor sandbox tenants: each visitor to the live demo gets an isolated tenant, so two people breaking the system on purpose at the same moment can't corrupt each other's view of it — proven with an automated test that fires conflicting chaos state at two tenants simultaneously.
Real Engineering Incidents
Two investigations, not two anecdotes
Measuring a Kafka migration meant finding three unrelated bugs first
2026-09 (Phase 10)Test failed
Built a complete second EventBus implementation on Kafka and ran the same load test used to validate the Postgres transport. Ingest performed comparably — but the drain check (how long the async pipeline takes to catch up) plateaued at a small residual lag and never reached zero, for the entire 60-second timeout.
Initial assumption
The obvious read: Kafka's consumers were stuck, or messages were being silently dropped somewhere in the new produce/consume path.
Investigation
Kafka's own admin API (rpk group describe) told a different story: TOTAL-LAG 0 for both consumer groups — the broker believed everything had been consumed. That ruled out a stuck consumer immediately and reframed the question: not 'why isn't Kafka finishing' but 'why does this project's own measurement disagree with Kafka's own record of what happened.' Re-running after a real fix to the new Kafka code changed the plateau's size but never closed it — proof that fix wasn't the actual mechanism, and cause for a second investigation.
Root cause
Two separate causes, found in sequence. First: the load-test script itself — unchanged and trusted since an earlier phase — generated overlapping event IDs across its concurrency waves, so a large fraction of 'unique' requests were actually duplicates. Correctly rejected by idempotency, but a rejected insert still consumes a database sequence value with no row created, inflating the drain target past the true number of real events every prior load test in the project had ever measured against. Second, after fixing that and re-running on a genuinely clean database: Kafka consumer sessions could time out under sustained backlog processing, since each message did two sequential database round trips with no explicit heartbeat in between — a real client-tuning gap, not a data-loss bug (every message's actual side effect always completed, cross-checked against the business tables directly).
Fix
Gave every load-test wave a distinct ID prefix, eliminating the cross-wave collisions entirely. Widened the Kafka consumer's session timeout and set an explicit heartbeat interval, addressing the rebalance risk directly instead of papering over its symptom.
Verification
Reset the database to a genuinely clean state and re-ran both transports back to back on identical, current code: Postgres reproducibly drained 6,200 real events in ~45 seconds — the first accurate number this project ever produced for that figure, meaningfully higher than what earlier, duplicate-cushioned runs had reported. The session-timeout fix cut the Kafka bookkeeping discrepancy by roughly 95%, reported as exactly that rather than rounded up to fully resolved.
Engineering lesson
A number that refuses to reach zero is a question, not a result to write down with a caveat. Three of this project's most senior findings — a stale load-testing tool, a real client-tuning gap, and a corrected historical claim — only surfaced because a small, easily-ignorable residual was treated as unacceptable instead of close enough.
A load test found the pipeline never caught up — an N+1 hiding at demo scale
2026-09 (Phase 06)Test failed
A custom load test pushed a 3,000-event burst through ingest, then measured how long the async pipeline took to drain. Ingest itself scaled cleanly to ~3,700 req/s. But one consumer group, projection-worker, was still over 5,600 events behind a full 60 seconds later — draining at roughly 9 events per second, nowhere close to keeping pace with a pipeline that had just accepted thousands of events per second.
Initial assumption
The obvious suspect was the transport — maybe Postgres itself couldn't keep up as a message queue under this load, which would have been read as early evidence that a Kafka migration was needed sooner than expected.
Investigation
Checked each of the three consumer groups' lag independently instead of treating 'the pipeline' as one thing: audit-log and workflow-trigger were both already at zero lag. Only projection-worker was behind — ruling out the transport itself and pointing at that one consumer's own code.
Root cause
refreshWorkflowProjection was issuing one INSERT ... ON CONFLICT statement per changed workflow row, in a plain sequential loop, on every single tick. Invisible at demo scale (tens of rows). At load-test scale, with 3,000+ workflow executions touched per tick, this was 3,000+ sequential round trips competing for the same connection pool every other consumer group and the API shared.
Fix
Replaced the per-row loop with one bulk INSERT ... SELECT ... JOIN (VALUES ...) ON CONFLICT statement — the VALUES list is bounded by the number of distinct workflow definitions (currently one), not the number of executions, so the whole refresh became one round trip regardless of backlog size.
Verification
Before: still 5,600+ events behind after 60 seconds. After: a fresh, more than doubled load test fully drained in what was measured at the time as 23.5–28 seconds — a number Phase 10's later investigation found had itself been measured against an inflated target (see the Kafka migration incident above); the fix was real and correct, the exact seconds it bought were revised once a more accurate measurement existed.
Engineering lesson
A per-row loop that's fine at the row counts you test with locally can become the dominant cost at the row counts a load test actually produces — and the fix is to check each independent stage of a pipeline separately before assuming the bottleneck is the most architecturally interesting-looking component (the transport), rather than the least (a loop).
13 Architecture Decision Records
Architecture Decisions
8 of 13 shown here — the ones with the most to say for themselves. All 13 are on GitHub.
ADR-002
Postgres as the Event Bus
FOR UPDATE SKIP LOCKED behind an EventBus interface, with named trigger conditions for migrating to Kafka — later built and measured for real (ADR-013), confirming the original call.
ADR-003
Transactional Outbox
The business row and its outbox row commit in the same transaction, closing the exact gap between 'DB commit succeeds' and 'bus publish fails' that a naive dual write leaves open.
ADR-004
Saga Orchestration, Not Choreography
A single WorkflowEngine owns step order and compensation order from one table, chosen because 'what has this execution done so far, and in what order should it be undone' needs one authoritative answer.
ADR-006
CQRS, Scoped
Read models fed by their own consumer group, with a 250ms measured staleness bound and one documented exception rather than a purist claim the code doesn't actually meet.
ADR-008
Multi-Tenancy: Shared Schema, One Chokepoint
Tenant resolution happens in exactly one function so cross-tenant leakage is a one-file audit — and names the real leak found and fixed on an operational table that felt lower-stakes than it was.
ADR-011
Trace Context as Persisted Data
OpenTelemetry spans linked across process restarts and async loops by storing trace/span IDs on the row itself, not by relying on ambient context that async workers don't share — verified as one 22-span trace.
ADR-012
Per-Visitor Sandbox Tenants
The direct fix for an earlier concurrency bug (a global fault-injection flag leaking between simultaneous workflows): each visitor now gets an isolated tenant, proven with an automated concurrent-chaos regression test.
ADR-013
The Kafka Migration, Measured
A complete second EventBus transport, built and load-tested against Postgres rather than assumed necessary — comparable throughput at this scale, three real bugs found producing that number, staying on Postgres in production.
Scaling
Measured, not planned — twice
LiveOps deliberately separates what has actually been run from what the architecture is designed to support later. The load test below exists specifically to turn assumptions about where this system would break into measurements — run against both event-bus transports, not just the one that shipped.
V1 — Modular Monolith
MeasuredOne Postgres instance carries ingest, the outbox, the event bus, the workflow engine, and CQRS projections all in one process — measured at 3,446 req/s (p95 43.8ms) locally, with two real bottlenecks found and fixed by load testing rather than assumed away.
V2 — Observability & the Kafka Question
MeasuredReal OpenTelemetry tracing, circuit breakers, dead-letter replay, and backpressure, plus the Postgres → Kafka question answered with an actual second implementation instead of an assumption: comparable ingest throughput between transports at this scale, and a corrected, more accurate drain-time figure (~45s for 6,200 real events) once a load-testing bug that had inflated every earlier number was found and fixed.
Load test: real bottlenecks, found and fixed
MeasuredThe plan's own targets called for measured numbers, not claimed ones — so the load test's job was to find where this specific implementation actually breaks, not to assume Kafka was needed before checking. Doing that for real, twice (once per transport), surfaced a bug in the load-testing tool itself that had been quietly inflating every drain-time figure in this project since it was first written.
A custom Node script (not k6/autocannon, since idempotency dedup would otherwise silently skew results) drives four waves of increasing concurrency against POST /api/v1/events with unique event IDs, then measures how long the async pipeline takes to fully drain by polling every consumer group's checkpoint against the event log's max position — run against both the Postgres and Kafka transports on an identically reset, clean database for a fair comparison.
| Concurrency | Requests | p50 | p99 |
|---|---|---|---|
| 10 | 200 req, 0 errors | 16.1ms | 80.1ms |
| 25 | 1,000 req, 0 errors | 24.0ms | 60.2ms |
| 50 | 2,000 req, 0 errors | 20.3ms | 55.8ms |
| 100 | 3,000 req, 0 errors | 28.2ms | 54.3ms |
Validates: Ingest meets the plan's stated local target (1,000 events/sec, p95<200ms) comfortably on both transports. A clean, reproducible measurement (not the original, since-corrected figure) puts Postgres's full-pipeline drain at ~45 seconds for 6,200 genuinely distinct events; Kafka's ingest throughput is comparable at this scale but pays a real consumer-group cold-start cost Postgres's immediate-poll design doesn't.
Does not validate:
- — A single unpooled Node process against one Postgres container with no resource limits — a local result, not a claim about any specific production instance size.
- — That Kafka would remain comparable at throughput levels this project's hardware was never pushed to reach — the decision to stay on Postgres is scoped to what was actually measured, not extrapolated past it.
Numbers That Are Real
Measured Metrics
Sustained ingest throughput (100 concurrent, 0 errors)
Measured3,446 req/s · p95 43.8ms · p99 54.3ms
Full pipeline drain, corrected measurement
Measured~45s for 6,200 real events (Postgres) — the earlier ~23.5s figure was measured against an inflated target and is corrected in the record, not deleted
A second event-bus transport, built and measured, not assumed
MeasuredKafka via Redpanda — comparable throughput to Postgres at this scale; staying on Postgres in production
Crash-resume tests actually run (kill -9 on the live process)
Measured4 — mid-outbox-batch, pre-dispatch, mid-saga, and post-compensation
Real bugs found and fixed during build/test, not invented for the writeup
Measured8 across both phases — including one in a load-testing tool trusted for months
Architecture Decision Records
Measured13 ADRs, each defending a decision the build actually had two live options for
Automated failure-test scenarios, passing on both transports
Measured7 — crash recovery, tenant isolation, saga compensation, circuit breaker, concurrent chaos isolation
Technology