Architecture

Event-Driven Microservices at Scale: Apache Kafka vs AWS SQS/SNS vs RabbitMQ for High-Volume Transaction Systems

An architectural trade-off analysis of Kafka, RabbitMQ and AWS SQS for transaction-processing workloads, covering rebalance storms, poison-pill DLQ topologies and idempotent consumer design.

Updated 9 min read
On this page

Executive Summary & Architecture Takeaways

  • The Myth of the Single "Best" Broker: There is no universal winner among Apache Kafka, RabbitMQ, and AWS SQS. Kafka is a distributed, append-only event stream log; RabbitMQ is a smart, flexible-routing message broker; AWS SQS is a fully managed queue service.
  • Throughput and Latency in Practice: Published benchmarks vary enormously with message size, batching, replication factor, acknowledgement settings and hardware, so treat any single number with suspicion and benchmark your own payloads. As a rule of thumb:
    • Apache Kafka: the highest sustained throughput per node thanks to sequential I/O and batching, with low-millisecond latency when tuned. Ideal for transaction ledgers, audit trails, and stream analytics.
    • RabbitMQ (Quorum Queues): lower peak throughput than Kafka but very low per-message latency and rich routing. Ideal for task distribution, priority scheduling, and request routing between services.
    • AWS SQS: no servers to operate and near-unlimited throughput on Standard queues, but higher per-call latency (an HTTPS API round trip) and cost that scales linearly with request volume.

1. Architectural Paradigms: Log vs Smart Broker vs Cloud Queue

To pick the correct messaging foundation, you must understand how data moves through their storage layers:

+-------------------------------------------------------------------------+
|                        APACHE KAFKA (Append-Only Log)                   |
|  [Offset 0] -> [Offset 1] -> [Offset 2] -> [Offset 3] -> [Offset 4]     |
|         ^                          ^                                    |
|    Consumer A (Analytics)      Consumer B (Billing Ledger)              |
|  * Messages persist for the retention period; consumers track offsets.  |
+-------------------------------------------------------------------------+
 
+-------------------------------------------------------------------------+
|                        RABBITMQ (Smart Broker / Queue)                  |
|  [Exchange] --(Routing Keys)--> [Queue A] -> Worker 1 (Ack/Nack)        |
|                             --> [Queue B] -> Worker 2 (Ack/Nack)        |
|  * Broker tracks per-message delivery state; deletes messages on ACK.   |
+-------------------------------------------------------------------------+
 
+-------------------------------------------------------------------------+
|                        AWS SQS (Managed Queue)                          |
|  Producer -> [SQS FIFO / Standard Queue] -> Poll (Visibility Timeout)   |
|  * Pull-based HTTPS API; redrive policy moves failures to a DLQ.        |
+-------------------------------------------------------------------------+

1.1 Apache Kafka: The Immutable Commit Log

Kafka does not track which consumer has read which individual message. Messages are written sequentially to disk in immutable partition segments and kept for a configured retention period (time- or size-based, or indefinitely with log compaction). Consumers independently poll batches and advance their committed offset.

  • Strength: High throughput, sequential disk I/O, event replay from days or months ago.
  • Weakness: No individual message deletion or priority queuing; you operate the cluster (KRaft metadata quorum, since ZooKeeper was removed in Kafka 4.0), partition balancing and disk capacity, unless you use a managed service.

1.2 RabbitMQ: The Smart Broker

RabbitMQ implements AMQP 0-9-1 (plus other protocols via plugins). Exchanges route messages to queues based on headers, direct keys, or wildcard topics. Quorum queues replicate each queue across nodes using Raft, and the broker tracks the delivery state of every message.

  • Strength: Highly flexible routing, priority queues, delayed delivery via plugin, immediate per-message ACK/NACK. RabbitMQ Streams add a replayable log for cases that need it.
  • Weakness: Throughput degrades when queues grow to millions of unacknowledged messages; broker state is memory-sensitive and needs careful capacity planning.

1.3 AWS SQS: Minimal Operations

SQS eliminates cluster patching, capacity planning, and broker monitoring.

  • Strength: Fully managed, pay-per-request, native AWS IAM integration.
  • Weakness: Vendor lock-in, cost that grows linearly with very high request volumes, and higher per-hop latency than a self-managed broker on the same network. FIFO queues have per-queue throughput limits (higher with batching and high-throughput mode).

2. A Practical Decision Framework

Use this decision tree as a starting point when designing new microservice topologies:

                    Do you need to REPLAY past events?
                               /       \
                             YES        NO
                             /           \
                     [Apache Kafka]     Do you need flexible routing
                                        & priority queues?
                                                /       \
                                              YES        NO
                                              /           \
                                      [RabbitMQ]       Is your stack fully
                                                        in AWS & you prefer zero ops?
                                                                /       \
                                                              YES        NO
                                                              /           \
                                                          [AWS SQS]   [RabbitMQ]

3. Surviving Kafka Consumer Rebalance Storms in Production

In a high-volume settlement system, one of the most dangerous failure modes is the Consumer Group Rebalance Storm.

3.1 Anatomy of the Rebalance Storm

  1. Consumer Node 4 encounters a slow database lock while processing a batch of 500 transaction records.
  2. The time between polls exceeds max.poll.interval.ms (default 300,000 ms / 5 minutes).
  3. The consumer leaves the group and the group coordinator triggers a rebalance.
  4. With the classic eager rebalance protocol, every consumer in the group revokes all of its partitions.
  5. All consumers pause processing while partitions are redistributed, which can take tens of seconds in large groups.
  6. The redistributed batch lands on another consumer, which also times out, and the group can enter a cascade of repeated rebalances.

3.2 The Engineering Solution

Three measures together make rebalance storms rare:

1. Switch to the Cooperative Sticky Assignor

Instead of revoking all partitions during a rebalance (eager approach), the cooperative protocol only moves the partitions that actually need a new owner. On Kafka 4.x brokers and clients you can alternatively adopt the new consumer group protocol (group.protocol=consumer, KIP-848), which moves assignment to the broker and is incremental by design.

# Java consumer config
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
# Smaller batches keep each poll loop short
max.poll.records=100
# Static membership avoids rebalances on quick restarts (one unique ID per instance)
group.instance.id=settlement-consumer-04

2. Decouple Polling from Business Execution

Never let long database transactions or external HTTP calls block the consumer from polling or heartbeating. The example below uses KafkaJS, where liveness is governed by sessionTimeout and explicit heartbeat() calls during long batches (the Java client uses max.poll.interval.ms instead):

// Consumer loop with bounded concurrency and regular heartbeats (KafkaJS)
async function runConsumer() {
  await consumer.run({
    eachBatchAutoResolve: false,
    eachBatch: async ({ batch, resolveOffset, heartbeat, isRunning, isStale }) => {
      for (const message of batch.messages) {
        if (!isRunning() || isStale()) break;
 
        // Execute heavy work in a bounded-concurrency worker pool
        await workerPool.submit(() => processPayment(message));
 
        resolveOffset(message.offset);
        await heartbeat(); // Keep the consumer alive with the coordinator
      }
    },
  });
}

3. Tune Timeouts to Reality

Measure the worst-case processing time per batch and set max.poll.records and max.poll.interval.ms so that a slow batch finishes well inside the interval. Combine this with timeouts on every downstream call so a hung dependency cannot stall the consumer indefinitely.


4. Poison-Pill DLQ Topologies & Circuit Breaking

When a microservice receives a malformed payload (for example an unexpected schema change or a corrupted decimal field), it must not crash the partition thread or halt processing.

4.1 Tiered Retry & Dead Letter Queue (DLQ) Pattern

[Main Topic: payments.v1]
        |
        v
[Consumer Worker] ---(Fails with Transient Error)---> [Retry Topic: payments.retry.1m]
        |                                                           |
        | (Succeeds)                                     (Retries exhausted)
        v                                                           v
[Database Commit]                                   [Dead Letter Queue: payments.dlq]
                                                                    |
                                                            [Alert On-Call]
                                                            [Manual Replay Tool]
  1. Immediate In-Memory Retry: 3 attempts with exponential backoff (100 ms, 500 ms, 2 s) for transient network timeouts.
  2. Delayed Retry Topic: If immediate retries fail, publish the message with an incremented x-retry-count header to a delayed retry topic (payments.retry.1m).
  3. Dead Letter Queue (DLQ): Once the retry budget is exhausted (for example after 5 total attempts), publish to payments.dlq alongside the error stack trace, original timestamp, and host metadata. Non-retryable errors such as schema violations should go straight to the DLQ.
  4. DLQ Replay API: Provide an administrative tool allowing engineers to inspect payloads and replay them to the primary topic after bug fixes.

5. Idempotent Consumer Implementation

Brokers typically give you at-least-once delivery to consumers, and Kafka's exactly-once semantics stop at the boundary of Kafka itself. Consumers that write to databases or call external APIs must therefore be idempotent: processing the same message twice must yield the same system state without duplicate debits or duplicate emails.

5.1 Redis Fast Path + Database Unique Constraint

The database unique constraint is the real guarantee; Redis is only a fast path and a guard against concurrent duplicates.

import Redis from 'ioredis';
import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/prisma';
 
const redis = new Redis(process.env.REDIS_URL!);
 
export async function processIdempotentEvent(
  eventId: string,
  payload: { accountId: string; amount: number }
): Promise<{ success: boolean; duplicate: boolean }> {
  const lockKey = `lock:event:${eventId}`;
  const processedKey = `processed:event:${eventId}`;
 
  // 1. Fast cache check
  const isAlreadyProcessed = await redis.get(processedKey);
  if (isAlreadyProcessed) {
    return { success: true, duplicate: true };
  }
 
  // 2. Short-lived lock to avoid concurrent processing of the same event (10s lease)
  const acquired = await redis.set(lockKey, 'locked', 'EX', 10, 'NX');
  if (!acquired) {
    throw new Error(`Concurrent processing in flight for event ${eventId}`);
  }
 
  try {
    // 3. Database transaction; the primary key on eventId guarantees deduplication
    await prisma.$transaction(async (tx) => {
      await tx.paymentTransaction.create({
        data: {
          id: eventId,
          accountId: payload.accountId,
          amount: payload.amount,
          status: 'COMPLETED',
        },
      });
 
      await tx.account.update({
        where: { id: payload.accountId },
        data: { balance: { increment: payload.amount } },
      });
    });
 
    // 4. Mark processed in the fast cache (7 days TTL)
    await redis.set(processedKey, '1', 'EX', 86400 * 7);
    return { success: true, duplicate: false };
  } catch (err) {
    // Unique constraint violation: the event was already applied earlier
    if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
      await redis.set(processedKey, '1', 'EX', 86400 * 7);
      return { success: true, duplicate: true };
    }
    throw err;
  } finally {
    // Release the processing lock
    await redis.del(lockKey);
  }
}

In production, release the lock only if it still holds your own token (a compare-and-delete Lua script), so a slow worker whose lease expired cannot delete a lock now held by another worker.


6. Architecture Comparison Matrix

FeatureApache KafkaRabbitMQ (Quorum)AWS SQS
Primary ArchitectureDistributed Append-Only LogAMQP Message BrokerManaged Queue Service
Relative ThroughputHighest (batching, sequential I/O)Moderate to highStandard: near-unlimited; FIFO: per-queue limits
Message OrderingStrict per-partitionPer-queue (single consumer)FIFO: per-MessageGroupId; Standard: best effort
Event ReplayabilityNative (rewind offset)No for queues (deleted on ACK); yes with StreamsNo (deleted after successful processing)
Routing FlexibilityTopic / key hashingDirect, Topic, Fanout, HeaderNone in SQS; fan-out and filtering via SNS or EventBridge
Operational ComplexityHigh (JVM, KRaft, disk)Medium (Erlang, memory)Minimal (fully managed)
Best Used ForTransaction logs, event sourcing, telemetryTask queues, service-to-service routingCloud-native apps, decoupling AWS workloads

Questions people ask

When should you choose Apache Kafka over RabbitMQ?

Choose Apache Kafka when you need event streaming with long retention, strict per-partition ordering, replay of past events (event sourcing, reprocessing) and very high horizontal throughput. Choose RabbitMQ when you need flexible routing (direct, topic, fanout and header exchanges), per-message acknowledgments, priority queues and classic task-worker distribution.

What causes Kafka consumer rebalance storms and how do you prevent them?

Rebalance storms occur when consumers stop polling for longer than max.poll.interval.ms (for example during long database writes or hung downstream calls). The group coordinator removes the consumer from the group and triggers a rebalance, which can cascade if the reassigned work times out again. Mitigations include the CooperativeStickyAssignor (or the new consumer group protocol in Kafka 4.x), smaller max.poll.records, static membership via group.instance.id, and moving heavy processing off the polling thread.

How do you guarantee exactly-once processing in distributed consumers?

Kafka's exactly-once semantics (idempotent producers plus transactions) cover read-process-write flows that stay inside Kafka. As soon as a consumer has side effects in an external system such as a database or payment API, you need consumer-side idempotency: a deterministic idempotency key enforced by a database unique constraint (an "inbox" or processed-messages table), optionally fronted by a Redis SET NX cache. The Transactional Outbox pattern solves the complementary producer-side problem of publishing events atomically with a database write.

KafkaRabbitMQAWS SQSMicroservicesEvent Driven
  1. Automate blog and social media posting with Claude, GitHub Actions and Make

    An architecture for publishing one researched article a day and turning it into a narrated vertical video for YouTube Shorts, Instagram, Facebook and LinkedIn, with the platform limits that shape it.

    AI engineering9 min read
  2. Google Workspace to Microsoft 365 Migration: A Complete Technical Guide for Mail, Calendar, Contacts and Drive

    A step-by-step guide to moving from Google Workspace to Microsoft 365 with Exchange Online's native Gmail migration and Migration Manager, from routing subdomains and service accounts to MX cutover.

    Microsoft 36515 min read
  3. Microsoft 365 Tenant-to-Tenant Migration Architecture: Cross-Tenant Mailbox, OneDrive, Teams and Domain Move

    A practical guide to Microsoft 365 tenant-to-tenant migration for mergers and divestitures: native cross-tenant mailbox and OneDrive moves, SharePoint, Teams and the domain cutover.

    Microsoft 36515 min read