In late autumn, roughly 30,000 runners line up on Lidingö island outside Stockholm for Lidingöloppet, the world's largest cross-country race. For more than a decade, the event's backend operations ran on an internal system called häst-Swedish for horse-a name chosen because the system had to "carry" the entire race day. That single word became shorthand inside the engineering team for a brittle but surprisingly resilient monolith.
A single Swedish noun-häst-became an unlikely case study in why race-day platforms fail under burst load, and how event-driven redesigns prevent it.
We spent eighteen months extracting the worst parts of häst into a modern event-driven platform. This article is a technical postmortem of that migration: what häst did well, where it broke, and which architectural decisions actually mattered under race-day conditions.
Why a Race Called Lidingöloppet Needed a System Named häst
Lidingöloppet has been held since 1965 and now includes distances of 30 km, 15 km, 10 km. And a trail race. Participants range from elite athletes to first-time runners, and the event relies on chip timing, medical alerts, live results. And volunteer coordination. Before the modern platform, all of that data flowed through häst, a set of interconnected services that originally began as a registration database.
The name häst emerged from a developer's joke: the system worked hard, never complained, and occasionally threw people off. But the technical reality was less romantic häst was a classic monolithic application written in Java 8 and backed by a single PostgreSQL instance. It had served the race for years because the team understood its behavior and because race day was short enough to tolerate manual workarounds.
Over time, however, the event added live tracking, SMS notifications,, and and integration with external timing hardwareEach feature increased the coupling inside häst. We found that the system's original designers had optimized for consistency and a single source of truth, not for burst throughput or partial failure. That tradeoff is common in legacy operational software. And it becomes dangerous when the business depends on a six-hour production window that can't be rerun.
The Monolith's Architecture: How häst Originally Solved Race Operations
At its core, häst used a three-tier design: a web front end for volunteers, a Java service layer for business logic. And PostgreSQL for storage. Runner records were keyed by a unique bib number, and each registration update triggered a synchronous write. Timing data arrived every few seconds from RFID mats placed along the course. But the ingestion path was a single queue processed by a fixed thread pool,
In normal conditions, häst performed adequatelyThe database handled about 4,000 transactions per minute. And the front end served roughly 120 concurrent staff users. The system also exposed a REST API for external partners, but it used long-lived synchronous HTTP calls with no retry budget, no circuit breaker, and no versioning. That API became the source of several race-day incidents when third-party apps retried aggressively during network congestion.
One thing häst did well was state management. A runner's status-registered, started, on course, finished, disqualified. Or withdrawn-was always stored in a single table with strict constraints. When a timing mat reported an unexpected sequence, the system flagged the discrepancy instead of silently overwriting data. That property later influenced how we designed the new event-sourced boundaries.
The Scaling Failure Points We Found in Production
During a wet and cold race day in 2019, häst began to exhibit cascading latency shortly after the first wave started. The immediate trigger was a surge of timestamp correction requests from the timing hardware: rain had degraded some RFID reads. And the hardware emitted duplicate events with slightly different timestamps. The consumption thread pool saturated, and the database connection pool followed.
The second failure point was the registration API. Volunteers at the start line were checking in late runners using tablets over a cellular network. Each check-in triggered a synchronous transaction that locked a row in the runner table. Slow network responses caused HTTP clients to time out and retry. Which multiplied the transaction load. The system did not distinguish between an idempotent retry and a new registration because häst had no idempotency keys.
The third issue was observability häst logged to flat files, and there was no structured logging, no metrics endpoint. And no distributed tracing. The operations team could see only CPU, memory, and database lock counts. In production environments, we found that the absence of application-level metrics made postmortems longer and less accurate than they should have been. We eventually added Prometheus metrics and OpenTelemetry tracing as part of the migration.
Replacing Cron-Based Polling with Event-Driven Workflows
The original häst used cron jobs for time-sensitive work such as publishing preliminary results, sending SMS updates. And synchronizing data with external partners. Cron worked until it did not. A delayed job could overlap with the next run. And there was no distributed lock to prevent duplicate execution. During the 2019 race, a results export job ran three times concurrently and produced inconsistent files.
We replaced the cron model with event-driven workflows built on Apache Kafka documentation topicsEach timing event, registration update, and volunteer action became an immutable event. Consumers could replay events without mutating source data. Which made results correction far simpler. We chose Kafka because we needed ordered, replayable logs. And because the operations team already had some experience with Kafka Connect for database change streams.
This change wasn't just architectural; it changed how the team reasoned about failure. Instead of asking "which cron job is stuck," we could ask "which consumer group is lagging. " Lag became a leading indicator of downstream problems, and we could scale individual consumer groups independently. The event-driven model also allowed us to build a dead-letter topic for poison messages, something häst never had.
For internal documentation and migration guidance, we wrote a short decision record that now lives in the repository. You can find similar patterns in our guide to event-driven microservices.
Choosing the Right Message Broker for häst Data Streams
The migration team debated between Apache Kafka, RabbitMQ. And NATS. RabbitMQ would have been easier for the existing developers, but its push-based delivery model made backpressure harder to manage under burst loads. NATS offered excellent latency but lacked the durable replay semantics we needed for result correction workflows.
Kafka's pull-based consumer model gave us control over processing rates. We configured the timing topic with 12 partitions to match the number of timing mats and assigned a dedicated consumer group to each downstream service. The broker handled roughly 18,000 events per second during peak race traffic, well above the old häst ingestion rate of 4,000 transactions per minute.
We also used Kafka Streams for stateful operations such as detecting a runner's first mat crossing and computing provisional paces. Before the migration, häst performed these calculations in memory inside the monolith, which meant a restart could lose state. Moving to Kafka Streams gave us local state stores with changelog topics. So recovery
.Need a Custom App Built?
Let's discuss your project and bring your ideas to life.
Contact Me Today →