Service Bus vs Event Grid vs Kafka: We Use All Three — Here's Exactly When to Pick Each (2026)
Mattrx runs Azure Service Bus, Event Grid, and Kafka side by side. The decision framework, real code, and exactly when to pick each.
- Author
- Randhir Jassal
- Published
- Reading time
- 22 min read
- Views
- 7 views
The wrong question is "which messaging platform should we standardize on?" The right one is "what shape is this message?" Mattrx runs Azure Service Bus, Event Grid, and Kafka in production — at the same time, for different jobs — because they solve genuinely different problems. This is the decision framework, the real code for each, the architecture, and the before/after of what happened when we used the wrong one.
TL;DR
Teams waste months arguing over one messaging platform to rule them all. There isn't one, because there are three different shapes of message:
- A command / work item ("generate this report") → Azure Service Bus — queues, ordering, transactions, scheduled delivery, dead-lettering.
- A reactive notification ("a blob was created", "notify these subscribers") → Event Grid — serverless push, webhooks, cheap fan-out, glue between services.
- A high-throughput event stream you may replay ("1.2B campaign events") → Kafka — partitioned log, retention, replay, stream processing.
Mattrx uses all three. Forcing one tool onto the wrong shape is where the pain comes from — we did it twice and undid it twice.
| Dimension | Azure Service Bus | Event Grid | Kafka |
|---|---|---|---|
| Best for | Commands / work queues | Reactive event notifications | Event streaming + replay |
| Model | Queue / topic (pull) | Pub/sub (push, webhooks) | Append-only log (pull) |
| Delivery | At-least-once, FIFO sessions, transactions | At-least-once, push + retry | At-least-once, per-partition order |
| Retention / replay | Consumed = gone (TTL) | Transient (retry window) | Retained log, replay anytime |
| Throughput | Thousands/sec | Millions of discrete events | Millions/sec, sustained |
| Ordering | Per-session FIFO | None | Per-partition |
| Scheduled delivery | Yes (native) | No | No |
| Protocol | AMQP | HTTP + CloudEvents | Kafka |
| Ops | Fully managed (Azure) | Fully managed (Azure) | Managed (Confluent) or self-run |
| Cost shape | Per-operation + tier | Per-million events (cheap) | Throughput units / brokers |
Production metrics (Mattrx, across the three workloads):
- Report-generation queue moved Kafka → Service Bus: native scheduled delivery + FIFO-per-tenant sessions removed ~300 lines of custom scheduling/ordering code; duplicate/early reports → 0.
- Blob-and-resource event glue moved Service Bus topic → Event Grid: cost ~$140/mo → ~$6/mo (per-million pricing), and the handler became a serverless trigger with zero polling.
- High-volume ingestion stayed on Kafka: 1.2B CampaignEvents, replayable; a downstream outage loses 0 events (see the dedicated Kafka post).
- Time to choose the right platform for a new feature: was a recurring debate → a 30-second flowchart (below).
- Operational surface: all three are managed; 0 brokers run by our 5-person team.
The one rule: match the platform to the message shape, not to a company standard. Command, notification, or stream — name the shape and the choice is obvious.
The 30-second decision
WHICH MESSAGING PLATFORM? — answer three questions
Is the consumer supposed to DO a unit of work, with ordering/retry/exactly-once-ish?
│ yes ───────────────────────────────────────────────► AZURE SERVICE BUS
│ (commands, work queues)
▼ no
Is it "something happened, react to it" — discrete, push, maybe many subscribers,
glue between services, low volume-per-event?
│ yes ───────────────────────────────────────────────► EVENT GRID
│ (reactive notifications)
▼ no
Is it a high-throughput STREAM you might replay / process as a log?
│ yes ───────────────────────────────────────────────► KAFKA (or Event Hubs)
│ (event streaming)
▼ no
→ it's probably a synchronous query. Use REST/gRPC, not a bus at all.
The one mental shift
The instinct — "pick one message bus and use it everywhere" — feels like good standardization. It's actually a category error, because "messaging" isn't one thing.
There are three message shapes, and each platform is built for one of them. A command wants a queue with ordering and transactions (Service Bus). A notification wants cheap, serverless, push fan-out (Event Grid). A stream wants a retained, replayable log (Kafka). Use the wrong shape's tool and you'll spend your time rebuilding the features the right tool gives you for free.
Once you sort each message into command / notification / stream, the "which platform" debate evaporates — and you stop bolting scheduled-delivery onto Kafka or high-throughput streaming onto a queue.
The running example: Mattrx uses all three
Mattrx is a multi-tenant marketing-analytics SaaS — 110k MAU, .NET 9 / ASP.NET Core, Azure SQL, ~3,200 req/sec peak, on Azure. Three messaging workloads, three platforms:
MATTRX MESSAGING ARCHITECTURE — the right shape for each job
┌─────────────────────────────────────────────────────────────────┐
│ ASP.NET Core API │
└───┬───────────────────────┬───────────────────────┬──────────────┘
│ command │ notification │ stream
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌──────────────┐
│ Service Bus│ │ Event Grid │ │ Kafka │
│ "generate │ │ "blob │ │ "events.raw" │
│ report" │ │ created", │ │ 1.2B events, │
│ FIFO/sched │ │ webhooks │ │ replayable │
└─────┬─────┘ └─────┬─────┘ └──────┬───────┘
▼ ▼ ▼
Reports worker Function / webhook analytics/enrich
(competing handlers (serverless, consumer groups
consumers) push) (stream processing)
Each section below is one of these workloads, why that platform, and the before/after of when we'd picked wrong.
Platform 1 — Azure Service Bus: commands & work queues
Use it when: a message is a command — a unit of work a consumer must do, where you care about ordering, exactly-once-ish processing, transactions, scheduled delivery, and competing consumers draining a queue.
Mattrx's report generation is the textbook case: "generate the month-end PDF for tenant X" is a command. We need per-tenant ordering (don't process a tenant's reports out of order), scheduled delivery (queue a report for 2am), and a dead-letter queue for failures.
Before — we'd forced this onto Kafka
// BEFORE — report commands on Kafka: we rebuilt scheduling + FIFO + DLQ by hand
// Kafka has no native scheduled delivery → we wrote a "delay topic" + timer hack.
// Kafka ordering is per-partition → we hashed tenant->partition manually for FIFO.
// ~300 lines reinventing what Service Bus does natively. Over-engineered for a queue.
After — Service Bus, where commands belong
// AFTER — send a report command, scheduled, session-ordered per tenant
var sender = client.CreateSender("reports");
await sender.SendMessageAsync(new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(cmd))
{
SessionId = cmd.TenantId.ToString(), // FIFO per tenant (sessions)
ScheduledEnqueueTime = cmd.RunAt, // native scheduled delivery — no hack
ContentType = "application/json",
}, ct);
// AFTER — the worker: competing consumers drain the queue, auto dead-letter on repeated failure
var processor = client.CreateSessionProcessor("reports", new ServiceBusSessionProcessorOptions
{
MaxConcurrentSessions = 8, // 8 tenants processed in parallel
AutoCompleteMessages = false,
});
processor.ProcessMessageAsync += async args =>
{
var cmd = JsonSerializer.Deserialize<GenerateReport>(args.Message.Body)!;
try
{
await _reports.GenerateAsync(cmd, args.CancellationToken);
await args.CompleteMessageAsync(args.Message); // ack only on success
}
catch (TransientException)
{
await args.AbandonMessageAsync(args.Message); // retried; after N tries -> DLQ automatically
}
};
processor.ProcessErrorAsync += e => { _log.LogError(e.Exception, "report processor"); return Task.CompletedTask; };
await processor.StartProcessingAsync(ct);
# diagnostic: inspect the dead-letter queue (failed commands park here, not lost)
az servicebus queue show --name reports --namespace-name mattrx-sb \
--query "countDetails.deadLetterMessageCount"
Mattrx metric: moving report commands Kafka → Service Bus deleted ~300 lines of custom scheduling/partitioning/DLQ code, and native sessions eliminated out-of-order report generation (duplicate/early reports → 0). Scheduled enqueue replaced a fragile timer service entirely.
Platform 2 — Event Grid: reactive notifications & serverless glue
Use it when: a message is a discrete notification — "something happened, react to it" — especially to glue Azure services together (blob created → process it), fan out to many subscribers, or push to webhooks. It's serverless, push-based (no polling), schema-light (CloudEvents), and priced per million events — cheap.
Mattrx uses it for two things: reacting to Azure resource events (a report PDF lands in blob storage → kick off post-processing) and fanning out discrete business events to tenant webhooks ("your export is ready").
Before — a Service Bus topic doing notification fan-out
// BEFORE — Service Bus topic + subscriptions + a polling worker just to react to a blob upload.
// Overkill: we polled a queue, ran a worker 24/7, and paid per-operation — for a discrete event
// Azure already emits natively. Wrong shape: this is a notification, not a work queue.
After — Event Grid, push + serverless
// AFTER — publish a discrete business event as a CloudEvent (fan-out to all subscribers)
var publisher = new EventGridPublisherClient(new Uri(topicEndpoint), new AzureKeyCredential(key));
await publisher.SendEventAsync(new CloudEvent(
source: "mattrx/exports",
type: "Mattrx.Export.Ready",
jsonSerializableData: new { tenantId, exportId, url }), ct);
// AFTER — an Event Grid-triggered handler (Azure Function). Push, serverless, zero polling.
[Function("OnExportReady")]
public async Task Run([EventGridTrigger] CloudEvent e, CancellationToken ct)
{
var data = e.Data!.ToObjectFromJson<ExportReady>();
await _webhooks.NotifyTenantAsync(data.TenantId, data.Url, ct); // push to the tenant's webhook
}
// Azure-native events work the same way: a Blob-Created event triggers post-processing,
// no worker polling storage, no custom plumbing.
EVENT GRID — one discrete event, fanned out to many handlers, push, serverless
"Export.Ready" ──► [tenant webhook] (subscriber 1)
──► [audit log function] (subscriber 2)
──► [email function] (subscriber 3)
add a subscriber = an Event Grid subscription; publisher never changes.
Mattrx metric: moving blob/resource reactions and export fan-out Service Bus topic → Event Grid cut that messaging cost ~$140/mo → ~$6/mo (per-million pricing on discrete events), removed an always-on polling worker, and made adding a new reaction a serverless subscription instead of a code change.
Platform 3 — Kafka: high-throughput streaming with replay
Use it when: a message is part of a high-throughput stream you want to retain, partition, and possibly replay — analytics pipelines, event sourcing, anything where the log itself is the source of truth.
This is Mattrx's /api/collect ingestion pipeline: 1.2B CampaignEvents, partitioned by tenant, consumed by independent groups (enrichment, analytics, persistence), with replay for reprocessing. We replaced a synchronous REST chain with it and cut failures ~90% — that migration is its own post.
The shape (brief — full teardown linked below)
// Kafka producer: append to the log and return fast; consumers drain async, can replay
await producer.ProduceAsync("events.raw", new Message<string, byte[]>
{
Key = ev.TenantId.ToString(), // per-tenant partition ordering
Value = JsonSerializer.SerializeToUtf8Bytes(ev),
}, ct);
// Consumer groups read the SAME log independently; offsets are consumer-controlled -> REPLAY.
WHY NOT SERVICE BUS HERE? — the numbers force it
- Volume: millions/sec sustained -> a queue's per-message model is the wrong cost/throughput shape
- Replay: we reprocess a bad day from the log -> queues delete on consume, Kafka retains
- Many independent readers of the SAME events -> consumer groups, not competing consumers
Mattrx metric: Kafka is the only one of the three that fits 1.2B retained, replayable, multi-consumer events — and it's why a downstream outage now loses 0 events instead of dropping tens of thousands. (Don't reach for it for the report queue, though — that's Service Bus's job.)
The full comparison, side by side
| Question | Service Bus | Event Grid | Kafka |
|---|---|---|---|
| "It's a command/work item" | ✅ best | ❌ | ⚠️ works, over-engineered |
| "React to a discrete event" | ⚠️ heavy | ✅ best | ❌ |
| "High-throughput stream + replay" | ❌ | ❌ | ✅ best |
| FIFO ordering | ✅ sessions | ❌ | ✅ per-partition |
| Scheduled / delayed delivery | ✅ native | ❌ | ❌ (hack) |
| Transactions | ✅ | ❌ | ⚠️ limited |
| Replay history | ❌ | ❌ | ✅ |
| Push to webhooks | ⚠️ | ✅ native | ❌ |
| Serverless trigger | ⚠️ | ✅ | ⚠️ |
| Cost for many tiny discrete events | ⚠️ per-op | ✅ cheapest | ⚠️ |
| Cost for huge sustained streams | ❌ | ❌ | ✅ |
| First-party Azure | ✅ | ✅ | ⚠️ (Event Hubs is) |
How they coexist (and combine)
You don't pick one — you route each message to its shape's platform, and they often chain. A common Mattrx pattern: Event Grid notices, Service Bus does the work.
COMBINING THEM — Event Grid for the trigger, Service Bus for the durable work
Blob "report.pdf" created
│ (Azure emits)
▼
Event Grid ──► Function ──► enqueue command ──► Service Bus "post-process"
(cheap notify) (translate event (durable, ordered, retried
into a command) work queue)
Event Grid is great at noticing things cheaply; Service Bus is great at reliably doing things. Kafka is great at remembering a high-volume stream. Used together, each does what it's best at.
Decision checklist
- Sorted the message into command / notification / stream before choosing a platform.
- Command (work + ordering + retry + maybe schedule) → Service Bus (sessions for FIFO, scheduled enqueue, auto-DLQ).
- Discrete reactive event / serverless glue / webhook fan-out / lowest cost-per-event → Event Grid (CloudEvents, push).
- High-throughput retained stream needing replay / multiple independent readers → Kafka (or Event Hubs for Azure-native).
- Needs an answer back synchronously → it's a query; use REST/gRPC, not a bus.
- Considered combining (Event Grid trigger → Service Bus work) rather than forcing one tool to do both.
- Idempotent consumers everywhere (all three are at-least-once).
- DLQ / retry strategy defined per platform (Service Bus auto-DLQ, Event Grid DLQ-to-storage, Kafka DLQ topic).
- Picked managed (Service Bus, Event Grid first-party; Kafka via Confluent/Event Hubs) unless you can staff brokers.
Honest stuff — the caveats and overlaps
-
Event Hubs is the Azure-native Kafka. If you want streaming and you're all-in on Azure, Azure Event Hubs (with a Kafka-compatible endpoint) often beats running Confluent — fewer vendors, native integration. We use Kafka via Confluent for ecosystem reasons; Event Hubs is the right default for many Azure shops. The "Kafka" column above largely applies to Event Hubs too.
-
Don't multiply platforms for the sake of purity. Three messaging systems is three things to learn, secure, and monitor. We run all three because we have all three shapes at real scale. A smaller app with only commands should run only Service Bus and stop there.
-
The shapes overlap at the edges. Event Grid can do some pub/sub that Service Bus topics also do; Kafka can do work-queue-ish things. The table shows the best fit, not the only possible fit. When it's genuinely ambiguous, pick the simpler/cheaper one and move on.
-
Service Bus has tiers with cliffs. Sessions, transactions, and larger messages need the Standard/Premium tiers. Price the tier you actually need; don't assume the basic tier covers FIFO.
-
Event Grid is fire-and-forget-ish. It retries and can dead-letter to storage, but it's not built for guaranteed ordered processing of work. If you need the message processed reliably and in order, that's Service Bus, even if Event Grid delivered the trigger.
-
Kafka/Event Hubs cost is throughput-shaped, not per-message. Cheap per event at high volume, but you pay for provisioned throughput even when idle. For low, bursty volumes a per-operation queue can be cheaper — match the cost model to your traffic.
-
What we'd do differently: start every messaging decision with the flowchart, not a preference. Both times we picked wrong (Kafka for the report queue, Service Bus topic for blob reactions) we'd skipped the "what shape is this?" question and reached for a familiar tool.
The closing mental model
"Messaging" is three different problems — command, notification, stream — and each has a platform built for it. Service Bus reliably does work; Event Grid cheaply notices events; Kafka durably remembers a stream. Sort the message into its shape first, and the platform chooses itself. Standardizing on one bus for all three guarantees you'll rebuild two of them badly.
Three habits this leaves you with:
- Name the shape before the platform. Command → Service Bus, notification → Event Grid, stream → Kafka/Event Hubs, answer-needed → REST. Thirty seconds, not a month of debate.
- Combine, don't force. Event Grid to notice cheaply, Service Bus to do reliably — let each play its position instead of overloading one.
- Stay managed and minimal. Run the fewest platforms that cover your real shapes; reach for the next one only when a genuinely new shape shows up at scale.
Further reading
- We Replaced REST with Kafka and Cut Failures by 90% — the full Kafka streaming implementation referenced in Platform 3.
- Outbox Pattern — A Complete Guide with Order Processing Example — reliably publishing to any of these from a DB write.
- SAGA Pattern in Microservices — A Complete Guide — coordinating multi-step workflows across messaging.
- When NOT to Use Microservices: A Decision Framework — messaging is for real seams, not an excuse to distribute.
- Azure Deploy Targets in 2026: App Service vs Container Apps vs AKS — the hosting decisions these messaging choices sit alongside.
Stuck choosing a messaging platform — or suspect you've forced the wrong one? Email randhir.jassal@gmail.com with the message ("X happened" / "do Y" / "stream of Z") and I'll tell you which shape it is and which platform it wants.
Get the next issue
A short, curated email with the newest posts and questions.