Software Engineering

Benchmarking Kafka vs Redis Streams: Throughput and Latency Done Honestly

10 min read
KafkaRedisSystem DesignTypeScriptBackend

Every Kafka vs Redis Streams comparison online eventually produces a table with a "throughput" column and a big number in it. Almost none of them tell you how that number was produced: what hardware, what payload size, what batch size, whether the broker was warm, whether anything else was running on the box. A number without a methodology behind it is not worth much, because you cannot tell what conditions produced it.

I spent this whole series building the same stock ticker twice, once on Kafka and once on Redis Streams, and by the end I wanted to know which one moved faster on my own machine. So I wrote a harness. What follows is the harness, not its output: what it pushes through each system, what it measures, how it computes percentiles, and where it will quietly mislead me if I read it carelessly. I am not going to state a single throughput or latency figure. Any number I published would be a fact about my laptop on a Monday evening, and it would tell you nothing about your cluster.

Two ways into the same function

I gave myself two entry points into one runBenchmark() function. One is a small CLI script I point at a message count and a payload size when I want a quick pass from the terminal, defaulting to 15,000 messages at 64 bytes if I cannot be bothered to type arguments. The other is a page in the dashboard, where a client component posts to an API route that calls the exact same function server side:

export async function POST(req: Request): Promise<Response> {
  const body = await req.json();
  const result = await runBenchmark(body.messages ?? 15000, body.payloadBytes ?? 64);
  return Response.json(result);
}

Both paths clamp their inputs before doing anything:

const n = Math.max(1000, Math.min(50000, Math.floor(messages)));
const bytes = Math.max(8, Math.min(4096, Math.floor(payloadBytes)));
const m = Math.min(1200, n); // how many of those n messages get a latency sample

Message count is bounded to 1,000 through 50,000, payload size to 8 through 4,096 bytes, and only up to 1,200 messages ever get a latency measurement. Timing the round trip of every message on a 50,000 message run would make the latency phase absurdly slow for no extra signal. The dashboard dropdowns (5,000 to 50,000 messages, 16 to 1,024 byte payloads) are presets inside those same bounds.

What gets measured, and in what order

Two dimensions, each run against both systems, sequentially:

sequenceDiagram
    participant H as Harness
    participant K as Kafka
    participant R as Redis
    H->>K: throughput run "batched producer.send"
    H->>K: latency run "producer + fresh consumer"
    H->>R: throughput run "pipelined XADD"
    H->>R: latency run "XADD + blocking XREAD"
    Note over H: one phase at a time, never concurrent

Kafka goes first, both its phases, then Redis. That ordering matters. If the two produced at the same time they would fight over the same CPU cores, the same Docker network bridge, and the same disk, and neither number would mean what I wanted it to mean. Running them sequentially isolates each system from the other, though it does nothing to isolate either of them from whatever else my laptop is doing.

Produce throughput

Both throughput functions do the same thing structurally: send n messages in batches of 1,000, time the whole loop, divide.

// Kafka: batched producer.send(), keyed across all 6 partitions
const producer = kafka.producer({ idempotent: true });
const BATCH = 1000;
const start = performance.now();
for (let i = 0; i < n; i += BATCH) {
  const end = Math.min(n, i + BATCH);
  const messages = [];
  for (let j = i; j < end; j++) messages.push({ key: `k${j % 6}`, value });
  await producer.send({ topic, messages });
}
const perSec = Math.round(n / ((performance.now() - start) / 1000));
// Redis: pipelined XADD, same batch size
const redis = createRedis();
const BATCH = 1000;
const start = performance.now();
for (let i = 0; i < n; i += BATCH) {
  const end = Math.min(n, i + BATCH);
  const pipe = redis.pipeline();
  for (let j = i; j < end; j++) pipe.xadd(key, "*", "p", value);
  await pipe.exec();
}
const perSec = Math.round(n / ((performance.now() - start) / 1000));

One structural asymmetry worth flagging: the Kafka run cycles keys k0 through k5 across the benchmark topic's 6 partitions, so it exercises the partition level parallelism that makes Kafka Kafka. The Redis run writes to a single stream key, because Redis has no built-in partitioning, a stream is one ordered log. That asymmetry is each system behaving the way it was designed to behave, not an oversight. Sharding the Redis side into several stream keys, the way I do with hash tags when co-partitioning matters, would be a fair follow-up experiment. My harness does not do it today.

The Kafka producer is created with idempotent: true, which under the hood forces acks=all. My default Compose setup is a single node, and the benchmark topics get created with replicationFactor: 1, so "all in-sync replicas" means exactly one replica. In a production multi-broker cluster, acks=all is doing real work and costing real round trips. On my laptop it is closer to a no-op, which is worth remembering before I read anything into the Kafka throughput figure.

End-to-end latency

Throughput tells me how fast I can shove messages in. Latency asks a different question, how long one message takes from "sent" to "received," and it needs a different setup: producer and consumer running concurrently, each message timestamped at send and measured again at receipt.

For Kafka, the harness spins up a fresh consumer group (bench-lat-<timestamp>), subscribes, and waits 900ms for the group to settle before producing anything. Skip that wait and you race the group join and lose the first several messages, which I learned the tedious way. Then it sends one message at a time, each carrying its send timestamp in a header:

await producer.send({
  topic,
  messages: [{ key: `k${i % 6}`, value, headers: { t: String(performance.now()) } }],
});

// in the consumer's eachMessage handler
const sent = Number(message.headers?.t?.toString() ?? "0");
latencies.push(performance.now() - sent);

The Redis analog uses a dedicated blocking connection, separate from the writer, following the same rule that keeps a blocking read from freezing every other command on the same connection. It issues raw XREAD ... BLOCK 1000 calls starting from $ (new entries only), while the writer stamps the send time into a field on the entry:

await writer.xadd(key, "*", "t", String(performance.now()), "p", value);

// read loop, dedicated blocking connection
const res = await reader.call("XREAD", "COUNT", "500", "BLOCK", "1000", "STREAMS", key, lastId);
// for each entry returned: latencies.push(performance.now() - Number(map.t))

Note XREAD rather than XREADGROUP: no consumer group, so none of the pending entries list bookkeeping lands inside the measurement. Both latency loops carry a 20 second overall timeout as a safety net, and both send one message at a time instead of batching, on purpose, because batching would measure batch flush latency and call it per-message latency.

Percentiles, computed plainly

Once the harness has a list of latency samples, one shared function runs over both systems' results:

function percentiles(samples: number[]): Percentiles {
  const s = [...samples].sort((a, b) => a - b);
  const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
  const sum = s.reduce((a, b) => a + b, 0);
  return {
    count: s.length,
    avgMs: sum / s.length,
    p50Ms: at(0.5),
    p95Ms: at(0.95),
    p99Ms: at(0.99),
    minMs: s[0],
    maxMs: s[s.length - 1],
  };
}

Two things I have to keep in mind whenever I read that output. First, at(q) is a nearest-rank estimator, not an interpolated one: it sorts the samples and indexes at floor(q * length). Standard and defensible, but tools that interpolate between ranks will hand me a slightly different p99 from identical raw data, so cross-tool comparisons are already shaky before anything else goes wrong.

Second, the harness discards no warm-up. Every sample counts, including the very first message of the run. Whatever one-time cost the opening messages pay, a TCP handshake, a consumer group settling, V8 warming up, is baked into the average and the percentiles. At low message counts a handful of slow outliers drag the mean around noticeably.

Why the shapes tend to differ

I will not hand out numbers, but I can explain why each system tends to behave the way it does, because those reasons are architecture rather than measurement.

graph TD
    subgraph Kafka
        KP["Producer batches records"] --> KL["Leader log segment (page cache)"]
        KL --> KF["fsync per broker flush policy"]
        KF --> KA["acks=all: wait for in-sync replicas"]
    end
    subgraph Redis
        RP["Client pipelines XADD"] --> RE["Single-threaded event loop"]
        RE --> RM["In-memory stream (RAM)"]
        RM --> RA["Async AOF append (everysec)"]
    end

Kafka's write path goes through a page cache backed, append-only log segment on disk. The broker does not fsync every record; it leans on sequential writes and OS page cache flushing, with the durability guarantee coming from replication (acks=all waiting on in-sync replicas) rather than a synchronous fsync per write. A deliberate trade: Kafka is built to sustain high throughput over disk-backed storage that can hold far more than RAM, and it gets there by batching hard (my harness batches 1,000 sends per producer.send() call, and a production KafkaJS producer batches internally on top of that via linger.ms and batch.size) and by treating the disk as a sequential log instead of a random-access store.

Redis Streams live in RAM, served by a single-threaded event loop with no lock contention between commands, because one command runs at a time. XADD on the hot path is a memory operation, not a disk write, which is the whole reason I kept calling Redis the low-latency edge throughout this series. Durability is opt-in and asynchronous: AOF with appendfsync everysec, my Compose default, batches disk writes about once a second instead of syncing every command, trading a small window of loss for speed. Pipelining amortizes network round trips the same way Kafka's batching amortizes broker round trips, by sending many commands and reading many replies in one exchange.

Neither model is faster in the abstract. One optimizes for a replicated, disk-backed log that survives broker loss and outgrows RAM; the other optimizes for an in-memory structure with no disk round trip on the hot path and no partition coordination to think about. Which one wins for a given payload size and batch size on a given machine is the question a harness exists to answer, one machine at a time.

The caveats I keep attached to every run

A few things this benchmark is not, and should never be mistaken for:

  • Single-node Docker on a laptop, not production. The default Compose setup is one KRaft node acting as both broker and controller, with benchmark topics at replicationFactor: 1. I do keep a separate three broker, replicationFactor: 3 profile, the one I used when I killed a leader and watched ISR failover, and the benchmark never touches it. Real clusters replicate across brokers and often across availability zones; real Redis deployments may run Cluster mode sharded across nodes. Neither of those gets measured here.
  • JS client overhead sits baked into both sides. KafkaJS and ioredis both run in the same Node.js process as the harness, on the same thread, competing for the same event loop as everything else Node is doing: V8 garbage collection, JSON.stringify on the Kafka payload, promise scheduling. Neither client is a zero-overhead binding to its broker. I am measuring "Kafka through KafkaJS" and "Redis through ioredis," never the brokers in isolation.
  • Localhost is not a network. Broker and client talk over a Docker port mapping to localhost, with essentially no network latency and no packet loss. Cross-host, cross-AZ, or cross-region deployments add real network time this setup cannot show.
  • One pass, not a load test. No warm-up phase, no repeated runs averaged together, no sustained load over minutes to see how either system behaves once page cache, JIT, and GC settle into steady state. One run captures one moment on one machine under whatever else was happening on it.

What would move the numbers meaningfully at scale: several concurrent producers and consumer groups instead of one of each, a real multi-broker cluster where acks=all waits on network round trips to other hosts, Redis Cluster sharding instead of one instance, disks with different fsync characteristics from mine, payloads above the 4,096 byte ceiling, and a duration long enough that garbage collection pauses and page cache pressure show up in the tail instead of hiding in the average.

Reading the output without fooling myself

When a run finishes I look at the shape of the distribution before I look at the average. A p50 close to the mean with a p99 far away is a tail latency story, and tails are what users feel on a request path. I compare the two systems only at identical message counts and payload sizes, since they scale differently as payloads grow: Kafka's page cache and network batching behave nothing like Redis pushing more bytes through RAM. And I run it more than once. A single 15,000 message pass with a browser and an IDE open is good for checking my intuition on my hardware today, and not for putting in a design doc.

Which is the reason I have no table for you. The only throughput figures worth defending are the ones measured on the hardware you plan to deploy on, with your payloads, under your load. Mine would not survive the trip to your infrastructure, and neither would anyone else's.

That leaves the question I started the whole exercise with: given everything the two systems do differently, which one should you pick? I have opinions now, and they are the next and last post.

Share:
Loading reactions...

Loading comments...