We had 50 cron jobs, 30 webhook receivers, and a Slack channel called #on-call-nightmares that had become a living monument to everything brittle about our event processing. Then we found Argo Events. Over three months, we replaced almost all of it. Here's what that actually looked like.
The Before State (It Was Bad)
Before Argo Events, our event processing looked like this: Bash scripts on cron, fired by Kubernetes CronJob objects. Webhook receivers written by whoever needed them, deployed as long-running Deployments with no shared reliability guarantees. Race conditions. Missed events when pods restarted mid-fire. No retry logic. No dead-letter handling.
The canonical failure mode: a game result would come in via Kafka, trigger a leaderboard update job, and if the job happened to restart mid-run (OOM, eviction, anything), the event was gone. The leaderboard wouldn't update. Players would notice. Someone would get paged. Someone would manually re-run a script. This happened, on average, twice a week.
The deeper problem was coordination. We had 30 different webhook receivers with no shared routing layer. Adding a new event type meant writing a new receiver, deploying it, figuring out where its logs were, and hoping nobody else was already listening to the same source for a different purpose.
What Argo Events Actually Is
Argo Events has three building blocks. Once you internalize these, everything else makes sense.
EventSource is where events enter the system. It listens to something: a Kafka topic, a webhook endpoint, an S3 bucket notification, a cron schedule, a GitHub push. It normalizes the event into a standard format and publishes it to the EventBus.
EventBus is the message backbone. By default it's NATS JetStream, a durable, at-least-once message bus that persists events even when consumers are temporarily unavailable. This is the piece that changed our reliability story.
Sensor subscribes to the EventBus, watches for specific events, and fires triggers: submit an Argo Workflow, make an HTTP call, apply a Kubernetes resource, post to Slack. One event can trigger many sensors. Many events can trigger one sensor with dependency conditions.
Our First EventSource: Kafka Game Results
The first thing we ported was our game result processing pipeline. A Kafka topic received game completion events; a cron job would batch-process them every 5 minutes. This meant leaderboards were always stale by up to 5 minutes, and if the batch job missed events, they were gone.
apiVersion: argoproj.io/v1alpha1
kind: EventSource
metadata:
name: game-results
namespace: argo-events
spec:
kafka:
game-result-topic:
url: kafka-broker.platform:9092
topic: game-results
partition: "0"
consumerGroup: argo-events-consumer
sasl:
mechanism: PLAIN
userSecret:
name: kafka-creds
key: username
passwordSecret:
name: kafka-creds
key: password
The Sensor that reacts to this triggers an Argo Workflow for each game result event: leaderboard update, prize credit, notifications. The whole pipeline is now event-driven, sub-second, and durable. If the Sensor pod restarts, NATS JetStream replays the event from the last committed offset. Nothing is lost.
The EventBus: Why NATS Changed Our Reliability Story
The old architecture had no message bus. Events were fire-and-forget HTTP calls. If the receiver was down, the event was gone.
EventBus with NATS JetStream gives you persistence, replay, and consumer groups. Events sit in the bus until a Sensor acknowledges them. If the Sensor crashes mid-processing, the event gets redelivered when it comes back. We went from "events are lost on pod restart" to "events are processed at least once, eventually, regardless of transient failures."
apiVersion: argoproj.io/v1alpha1
kind: EventBus
metadata:
name: default
namespace: argo-events
spec:
nats:
native:
replicas: 3 # HA — survives single node failure
auth: token
persistence:
storageClassName: ssd
accessMode: ReadWriteOnce
volumeSize: 20Gi # keep events for replay
The Double-Processing Bug: An Incident It Solved Retroactively
Before Argo Events, we had a leaderboard corruption bug that happened roughly once a month. A game result would be processed twice — once by the cron batch, once by a webhook receiver that was also watching the same Kafka topic for a different reason. Two writes to the same leaderboard entry, in rapid succession, with a race condition on the score update. Leaderboard corrupted. Manual rollback required.
Argo Events doesn't solve race conditions by magic. But it forces you to have one canonical
consumer per event type. The EventSource consumes from Kafka. The Sensor subscribes to the
EventBus. One processing path, one consumer group, NATS handling delivery. We also enabled
leaderElection on the Sensor to prevent duplicate triggers from multiple Sensor
replicas. The double-processing bug stopped occurring. Just gone.
Rolling It Out Across 200+ Services
We didn't migrate everything at once. We started with the game result pipeline: highest pain, most visible. Ran it in parallel with the old cron system for two weeks, comparing outputs. When the outputs matched and reliability improved, we killed the cron.
Then we did the webhook receivers. Thirty of them, over 6 weeks, one team at a time. Each team got a template for their EventSource and Sensor, a migration guide, and ownership of their own event namespace. The shared EventBus meant they didn't have to run their own message infrastructure. They just wrote the EventSource and Sensor YAML.
The Numbers
The Catch: YAML Complexity Grows Fast
Argo Events is powerful and production-ready. It's also verbose. A simple "Kafka event triggers a workflow" setup is 3 YAML files with ~80 lines total. When you have dependencies between events (trigger only if event A AND event B arrive within 30 seconds), the Sensor YAML gets complex fast. I'm not going to pretend it's clean.
Template everything. We built a Helm chart with sensible defaults for our most common patterns. New teams fill in 5 variables and get a production-ready EventSource + Sensor. Without that abstraction, the raw YAML would have killed adoption.
- One EventBus per cluster — simpler ops, shared reliability guarantees.
- Enable
leaderElectionon all production Sensors — prevents duplicate triggers. - Namespace Sensors by team or domain — makes RBAC and log querying sane.
- Test your failure scenarios: kill the EventSource pod, kill the Sensor pod, kill a NATS node. Verify events are replayed correctly before declaring victory.
The #on-call-nightmares Slack channel still exists. It's mostly quiet now. Someone suggested archiving it. We're keeping it as a monument to what we used to have.