Skip to content

Partitions & Ordering

The partition is Kafka’s unit of both parallelism and ordering: more partitions mean more throughput, but Kafka only guarantees order within a partition — never across the whole topic.

Kafka gives you a strong ordering guarantee, but it is scoped to a single partition. Within orders-0, offset 5 always follows offset 4, forever. Across partitions there is no defined order: offset 5 of orders-0 and offset 5 of orders-1 could have been written in either real-time order, and consumers may read them in either order.

orders-0: [a0][a1][a2][a3] ← totally ordered
orders-1: [b0][b1][b2] ← totally ordered
← a2 vs b1: NO defined order

This is not a limitation to work around so much as the price of horizontal scale. A single totally-ordered log cannot be written faster than one machine can append. Splitting into partitions lets many machines append in parallel — you trade global order for throughput.

Proof: writes interleaved in real time, reads not interleaved at all

Section titled “Proof: writes interleaved in real time, reads not interleaved at all”

To make “no defined order across partitions” concrete rather than abstract, this sends to two partitions interleaved in real send order (a0, b0, a1, b1, a2, b2, a3, b3) and then consumes everything back:

src/partitions-and-ordering/ordering-interleave.ts
// @run
import { Kafka } from 'kafkajs';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
const prefix = process.env.TOPIC_PREFIX ?? '';
const topic = `${prefix}ordering-interleave`;
const kafka = new Kafka({ clientId: 'ordering-interleave', brokers });
const admin = kafka.admin();
await admin.connect();
await admin.createTopics({ topics: [{ topic, numPartitions: 3 }] }).catch(() => {});
await admin.disconnect();
const producer = kafka.producer();
await producer.connect();
// Send order in real time: a0, b0, a1, b1, a2, b2, a3, b3 -- genuinely interleaved.
for (let i = 0; i < 4; i++) {
await producer.send({ topic, messages: [{ partition: 0, value: `a${i}` }] });
await producer.send({ topic, messages: [{ partition: 1, value: `b${i}` }] });
}
await producer.disconnect();
const consumer = kafka.consumer({ groupId: `${prefix}ordering-interleave-group` });
await consumer.connect();
await consumer.subscribe({ topic, fromBeginning: true });
const seen: string[] = [];
await new Promise<void>((resolve) => {
consumer.run({
eachMessage: async ({ partition, message }) => {
seen.push(`p${partition}:${message.value!.toString()}(offset ${message.offset})`);
if (seen.length === 8) resolve();
},
});
setTimeout(resolve, 8000);
});
await consumer.disconnect();
console.log('consumed order (as delivered to the app):');
console.log(seen.join('\n'));
process.exit(0);

Real output from this exact script, run against a Kafka 4.3.1 broker:

consumed order (as delivered to the app):
p1:b0(offset 0)
p1:b1(offset 1)
p1:b2(offset 2)
p1:b3(offset 3)
p0:a0(offset 0)
p0:a1(offset 1)
p0:a2(offset 2)
p0:a3(offset 3)

Within p1: b0, b1, b2, b3 — strictly the order they were written, every time. Within p0: a0, a1, a2, a3 — same. But the two logs were written interleaved (a0, b0, a1, b1, ...) and the consumer delivered all of p1 before any of p0 — the opposite of interleaved. That is not a bug: nothing in the protocol promises a relationship between partitions’ real-time write order and a consumer’s fetch/delivery order. Per-partition order is the only guarantee; cross-partition order is undefined and, as shown here, can look nothing like real time.

Within a consumer group, a partition is read by exactly one consumer at a time. That makes the partition count the ceiling on how many consumers can work in parallel:

  • 6 partitions, 3 consumers → each reads 2 partitions.
  • 6 partitions, 6 consumers → each reads 1 (maximum parallelism).
  • 6 partitions, 8 consumers → 6 work, 2 sit idle with nothing to assign.

If you want to scale consumption later, you must have provisioned enough partitions up front — consumers cannot subdivide a single partition.

flowchart LR
  subgraph topic["Topic: orders (4 partitions)"]
    p0["orders-0"]
    p1["orders-1"]
    p2["orders-2"]
    p3["orders-3"]
  end
  p0 --> c1["Consumer 1"]
  p1 --> c1
  p2 --> c2["Consumer 2"]
  p3 --> c3["Consumer 3"]
  idle["Consumer 4 (idle: no partition left)"]
Partition count caps how many consumers can work in parallel

Proof: adding partitions remaps keys mid-flight

Section titled “Proof: adding partitions remaps keys mid-flight”

Because the default partitioner computes hash(key) % numberOfPartitions, changing the partition count changes the divisor — and therefore where a key lands. The equivalent CLI command for growing a topic is:

kafka-topics.sh --bootstrap-server localhost:9092 --alter --topic remap-demo --partitions 6

This proves it the same way --alter --partitions does it under the hood (the admin API’s createPartitions, which kafka-topics.sh --alter itself calls) — one continuous script sends the same key before and after, in strict order, so there is no ambiguity about which write happened under which partition count:

src/partitions-and-ordering/remap-demo.ts
// @run
import { Kafka } from 'kafkajs';
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
const prefix = process.env.TOPIC_PREFIX ?? '';
const topic = `${prefix}remap-demo`;
const kafka = new Kafka({ clientId: 'remap-demo', brokers });
const admin = kafka.admin();
await admin.connect();
await admin.createTopics({ topics: [{ topic, numPartitions: 3 }] }).catch(() => {});
const producer = kafka.producer();
await producer.connect();
const [before] = await producer.send({ topic, messages: [{ key: 'user-42', value: 'before-alter' }] });
console.log(`BEFORE alter (3 partitions): key=user-42 -> partition ${before.partition}`);
// Same operation kafka-topics.sh --alter --topic remap-demo --partitions 6 performs.
await admin.createPartitions({ topicPartitions: [{ topic, count: 6 }] });
await admin.disconnect();
await producer.disconnect();
// A FRESH producer, not the same one, deliberately -- see the gotcha below.
const producer2 = kafka.producer();
await producer2.connect();
const [after] = await producer2.send({ topic, messages: [{ key: 'user-42', value: 'after-alter' }] });
console.log(`AFTER alter (6 partitions): key=user-42 -> partition ${after.partition}`);
await producer2.disconnect();
process.exit(0);

Real output from this exact script, run against a Kafka 4.3.1 broker:

BEFORE alter (3 partitions): key=user-42 -> partition 1
AFTER alter (6 partitions): key=user-42 -> partition 4

Same key, same producer logic, different partition — because hash("user-42") % 3 = 1 but hash("user-42") % 6 = 4. Every record for user-42 written before the alter stays on partition 1 forever; every record written after lands on partition 4. If anything relied on all of user-42’s events being in one partition (ordering, or a Kafka Streams state store keyed by partition), that guarantee just broke — silently, with no error from either the alter or the next send(). The keys-and-partitioning lesson covers this in depth, including why you provision partition count up front for keyed topics.

Producer-side reordering: max.in.flight and retries

Section titled “Producer-side reordering: max.in.flight and retries”

Partition order can also break before anything reaches a partition, at the producer. If maxInFlightRequests is greater than 1 and a batch fails and is retried, a later batch that already succeeded can end up durably ordered before an earlier one that took a retry round-trip — the partition itself never reorders anything it has accepted, but two separate producer requests can arrive out of their original order. Setting maxInFlightRequests: 1 (KafkaJS) prevents this at a throughput cost; the idempotent producer (idempotent: true) solves it with sequence numbers instead, at no ordering cost. That mechanism, proven with real sequence numbers from kafka-dump-log.sh, belongs to the producer module — see idempotent-producer for the full proof.

A partition is not an abstraction — it is a directory of real files. Kafka 4.3.1’s default log.dirs is /tmp/kafka-logs, and every partition gets /tmp/kafka-logs/<topic>-<partition>/:

scripts/partitions-and-ordering/list-segments.sh
TOPIC="${TOPIC_PREFIX:-}segments-demo"
/opt/kafka/bin/kafka-topics.sh --bootstrap-server $BOOTSTRAP --create --topic "$TOPIC" --partitions 1 --if-not-exists
for i in 1 2 3; do echo "record-$i" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic "$TOPIC"; done
ls -la "/tmp/kafka-logs/${TOPIC}-0/"

Real output (own scratch broker, one partition after 3 produced records):

total 20
drwxr-xr-x 2 appuser appuser 4096 Sep 28 22:57 .
drwxr-xr-x 59 appuser appuser 4096 Sep 28 22:58 ..
-rw-r--r-- 1 appuser appuser 10485760 Sep 28 22:57 00000000000000000000.index
-rw-r--r-- 1 appuser appuser 428 Sep 28 22:57 00000000000000000000.log
-rw-r--r-- 1 appuser appuser 10485756 Sep 28 22:57 00000000000000000000.timeindex
-rw-r--r-- 1 appuser appuser 8 Sep 28 22:57 leader-epoch-checkpoint
-rw-r--r-- 1 appuser appuser 43 Sep 28 22:57 partition.metadata

Three files per segment (the log starts with one segment, 00000000000000000000.*, named after its base offset): .log holds the actual records (real bytes, grows with every append — that’s why it is only 428 bytes here despite the .index/.timeindex files being pre-allocated at 10 MiB, the index.max.bytes-driven ceiling, not their live size); .index maps offsets to byte positions in the .log file for fast seeks; .timeindex does the same for timestamps (powers --offsets-for-times and time-based seeking). leader-epoch-checkpoint tracks leader-epoch boundaries (used to fence stale followers after a leader change); partition.metadata holds the partition’s topic ID. A partition growing over time means more segments, not one ever-growing file — the retention-and-compaction lesson proves segment rolling and deletion directly on these same files.

Choosing a partition count is choosing a point on a curve:

  • More partitions → higher throughput and more consumer parallelism, but more open files, more metadata, and longer rebalances.
  • Fewer partitions → simpler and cheaper, but a lower throughput ceiling.
  • Ordering need → if a set of events must stay strictly ordered relative to each other, they must land on the same partition — proven above to require planning ahead, since altering partition count remaps keys.

The practical rule: partition for your target throughput plus headroom, and make sure related events that need ordering share a partition.

🐛 Spot the bug

src/partitions-and-ordering/global-order-assumption.ts
import { Kafka } from 'kafkajs';
const kafka = new Kafka({ clientId: 'order-service', brokers: ['localhost:9092'] });
const consumer = kafka.consumer({ groupId: 'order-timeline' });
await consumer.connect();
await consumer.subscribe({ topic: 'order-events', fromBeginning: true });
// Build a single global timeline of every order event across the whole topic
const timeline: string[] = [];
await consumer.run({
eachMessage: async ({ message }) => {
// Assumes offsets increase in the same order events actually happened,
// across the WHOLE topic, so this array is "the" order of events.
timeline.push(message.value!.toString());
},
});
Show the answer

This compiles and runs without error, and for a single-partition topic it would even be correct. The bug is the assumption baked into the comment: “offsets increase in the same order events actually happened, across the whole topic.” Kafka only guarantees order within a partition (proven above with the interleaved-write test — p1’s four records arrived before any of p0’s, even though they were written interleaved in real time). If order-events has more than one partition, timeline is not “the order things happened” — it is an arbitrary interleaving of N independently-ordered logs that depends on fetch timing, not event time. The fix is to either keep the topic single-partition (kills throughput and parallelism), or track order per key (e.g. per orderId) using each record’s own partition + offset instead of a shared array, and never assume a cross-partition timeline is meaningful without an explicit timestamp-based reconciliation step.

Think of a topic as a shelf of independent notebooks, one per partition, not one long scroll. Each notebook is written front-to-back, strictly, forever — you can always trust the order of pages within one notebook. But nothing says notebook 3’s page 10 was written before or after notebook 1’s page 10; they’re separate books being filled by separate (or the same, but independently-scheduled) writers. “Reading the topic” means picking up notebooks in whatever order is convenient, not reading pages in a single unified sequence.

  • Single partition — perfect global order, zero write parallelism (one machine, one disk, one append stream is the ceiling). Fine for low-throughput topics that truly need total order.
  • Many partitions — high write and read parallelism, but ordering only survives within each partition; anything that must stay ordered has to be steered onto the same partition via keys (next lesson), and that steering breaks if partition count ever changes (proven above).
  • maxInFlightRequests: 1 — restores strict per-partition send order even under retries, at the cost of one in-flight request at a time (lower producer throughput). The idempotent producer gets the same guarantee without this cost — see the producers module.
  • Adding partitions later — the cheap, reversible-looking operation (--alter --partitions) is not free: it silently remaps every key’s destination going forward (proven above), which is a correctness change disguised as a capacity change.
  • “Kafka guarantees order across the whole topic.” No — proven above: interleaved real-time writes across two partitions came back completely reordered (all of one partition, then all of the other). Order is per-partition only.
  • “Adding partitions is a safe, purely-additive scaling operation.” No — proven above: the exact same key moved from partition 1 to partition 4 the moment the topic went from 3 to 6 partitions, because the partitioner’s hash is % numberOfPartitions.
  • “More partitions always means better performance.” Not unconditionally — more partitions means more open file handles per broker, more replication traffic, more metadata, and longer rebalances; it raises the throughput ceiling, it does not lower latency by itself.
  • “A partition is a logical concept, not real files I could go look at.” It is real files — proven above with a live ls of /tmp/kafka-logs/<topic>-0/ showing the actual .log/.index/.timeindex segment files Kafka just wrote.
What ordering does Kafka guarantee?
A topic has 4 partitions and a consumer group has 6 consumers. What happens?
What real, provable effect does kafka-topics.sh --alter --partitions have on a keyed topic, shown above?
What can reorder records within a single partition, proven conceptually above, even though the partition itself never reorders what it has already accepted?
What did ls /tmp/kafka-logs/<topic>-0/ show, proven above, about what a partition physically is?