DEV Community

Akın Coşkun
Akın Coşkun

Posted on

Why I Chose Kafka Over a Simple Job Queue for a Solo-Built Incident Management Tool

TL;DR: I built OpsFlow, a B2B incident management tool, and the hardest decision wasn't the UI or the on-call rotation logic — it was picking Kafka over a much simpler Redis-backed job queue for the alert pipeline. Here's why the "boring, simple" choice would have actually cost me more.

The problem

OpsFlow needs to take an incoming alert (from a monitoring tool, a webhook, or a manual page) and fan it out to multiple things at once: notify the right on-call engineer, update a status page, log a timeline event, and potentially trigger an escalation policy if nobody acks in time. Each of these is a separate concern, they fail independently, and they need to happen reliably even if one consumer is temporarily down.

My first instinct, since I was building this solo with zero infrastructure budget, was to skip Kafka entirely. A Redis list with BLPOP and a couple of worker processes would have gotten me 80% of the way there in an afternoon.

Why I didn't stop at Redis

Two things changed my mind once I actually mapped out the failure modes:

Replay matters for incidents. If the notification service crashes for three minutes, I don't want those alerts gone — I want every consumer to pick up exactly where it left off once it's back. A Redis list is destructive on pop; once a worker reads a message, it's gone if that worker dies mid-processing. Kafka's consumer groups with offset tracking meant I could restart a crashed consumer and it would resume from the last committed offset, no lost alerts.

Multiple independent consumers, one event. Notification, status page, timeline, and escalation are four different services that all need to see the same alert. With Redis I'd have needed to either duplicate the message four times at publish time (fragile — what if I add a fifth consumer later?) or build a pub/sub fan-out myself. Kafka topics with multiple consumer groups gave me that for free — each service reads the same topic independently, at its own pace.

What it actually looks like

// producer: alert-ingest service
await kafka.producer().send({
  topic: 'incidents.alerts',
  messages: [{
    key: incident.serviceId,
    value: JSON.stringify({
      incidentId: incident.id,
      severity: incident.severity,
      receivedAt: new Date().toISOString(),
    }),
  }],
});

// consumer: notification-service, its own consumer group
await kafka.consumer({ groupId: 'notification-service' }).run({
  eachMessage: async ({ message }) => {
    const alert = JSON.parse(message.value!.toString());
    await notifyOnCall(alert);
  },
});
Enter fullscreen mode Exit fullscreen mode

Keying by serviceId matters more than it looks — it guarantees alerts for the same service land on the same partition, so escalation logic that needs to see events in order for one service never gets them out of sequence, even though alerts for different services process fully in parallel.

The honest trade-off

Running Kafka solo isn't free. I run it as a single-broker instance, which means I gave up the durability guarantees a real cluster gives you — if that one broker dies, I lose the ability to produce or consume until it's back. For a tool that's currently serving a handful of early customers, that's an acceptable trade for now. It's the kind of decision I'll revisit the day OpsFlow has enough traffic that "single broker" stops being funny.

If you're building something similar and trying to decide between "the simple thing" and "the correct thing," my rule of thumb ended up being: if losing a message silently is something you'd have to explain to a customer during an incident, don't use a destructive queue.

OpsFlow is free while it's early — if you're running on-call for a small team and curious, the link's in my profile.

Top comments (0)