Building a Kafka to WebSocket bridge #
Your order events, sensor readings and audit trail already flow through Kafka, and the product team wants them live in the browser. The first prototype creates a Kafka consumer per WebSocket connection, subscribed to the topic and filtering for the user’s data. It works for ten users. At five hundred, the Kafka cluster is rebalancing consumer groups constantly, brokers are serving the same partitions hundreds of times over, and connection churn causes minute-long delivery stalls. Kafka is an excellent source for real-time UIs, but it is not a fan-out system for browsers: its unit of consumption is a partition read by a consumer group member, not a per-user channel. A bridge sits between the two, consuming Kafka once per node and routing to sockets in memory.
Root cause #
Kafka organizes data into topics split into partitions; each consumer group divides the partitions among its members and tracks a committed offset per partition. That model is built for a modest number of long-lived consumers processing everything — not thousands of short-lived, selective ones.
Mapping one WebSocket to one consumer breaks it in three ways. Consumer group churn: every connect and disconnect triggers a group rebalance, pausing consumption for all members while partitions are reassigned. Redundant reads: each per-socket consumer (in its own group, to see all partitions) re-reads the entire topic from the brokers, multiplying broker egress by the number of users. Wasted filtering: each consumer discards almost everything it reads, because each user cares about a tiny slice.
Resolution #
Choose one of two layouts depending on whether WebSocket clients can be routed to the node that owns their partitions.
Broadcast layout (simpler). Every bridge node consumes every partition — each node uses its own consumer group, or assigns all partitions manually without group management — and routes events to its local sockets. Broker egress is nodes × topic throughput, which is fine for moderate volumes and a handful of nodes.
Partition-affinity layout (scales further). Bridge nodes share one consumer group, so each partition is consumed by exactly one node, and a load balancer routes each client to the node that owns its key’s partition (consistent hashing on the same key Kafka partitions by). Broker egress is 1 × topic throughput, but rebalances move clients between nodes.
The code below implements the broadcast layout with KafkaJS and manual offsets, since per-node groups with no commits keep it simple and let clients resume from Kafka offsets directly.
import { Kafka, logLevel } from 'kafkajs';
import type { WebSocket } from 'ws';
const kafka = new Kafka({ clientId: `ws-bridge-${process.env.HOSTNAME}`, brokers: ['kafka-0:9092', 'kafka-1:9092'], logLevel: logLevel.WARN });
// Own group per node: every node sees every partition; nothing is shared or rebalanced across nodes.
const consumer = kafka.consumer({ groupId: `ws-bridge-${process.env.HOSTNAME}`, sessionTimeout: 30_000 });
const socketsByAccount = new Map<string, Set<WebSocket>>();
const SKIP_IF_BUFFERED = 1_000_000;
await consumer.connect();
await consumer.subscribe({ topic: 'orders', fromBeginning: false }); // live tail; resume handled below
await consumer.run({
autoCommit: false, // offsets are per-client resume tokens, not group state
eachMessage: async ({ partition, message }) => {
const account = message.key?.toString();
if (!account) return;
const targets = socketsByAccount.get(account);
if (!targets) return; // nobody on this node cares: cheap drop
// The (partition, offset) pair is a precise resume point for this account's stream.
const frame = JSON.stringify({
type: 'order.event', partition, offset: message.offset, data: JSON.parse(message.value!.toString()),
});
for (const ws of targets) {
if (ws.readyState === ws.OPEN && ws.bufferedAmount < SKIP_IF_BUFFERED) ws.send(frame);
}
},
});
export function register(account: string, ws: WebSocket) {
let set = socketsByAccount.get(account);
if (!set) socketsByAccount.set(account, (set = new Set()));
set.add(ws);
ws.once('close', () => { set!.delete(ws); if (set!.size === 0) socketsByAccount.delete(account); });
}
For resume, a client reconnecting with { partition, offset } can be served its missed events by a short-lived reader that fetches from that offset for that partition, filters by key, and hands off to the live tail. Because an account’s events are all in one partition (Kafka partitions by key), a single offset is a complete resume token — the same idea as the sequence-based resume in resuming WebSocket sessions after reconnect. If the gap is older than the topic’s retention, fall back to a snapshot from the system of record.
When an event must reach users on many nodes, or topics carry more than the bridge nodes can each consume, put a fan-out bus between the bridge and the WebSocket tier: a small number of bridge consumers publish each event to a per-account channel on Redis or NATS, and WebSocket nodes subscribe only to accounts with local members — see NATS vs Redis for WebSocket fan-out.
Edge cases #
Hot keys. Kafka partitions by key, so one very active account lands on one partition and one consumer thread. If a single key’s rate exceeds what the bridge can route, the bottleneck is the key, not the cluster — batch its events before sending, as in coalescing high-frequency WebSocket updates.
Consumer lag and live UIs. A bridge that falls behind delivers stale “live” data. Export consumer lag per partition and alert on it in seconds, not messages; a lagging bridge should shed non-critical traffic rather than deliver everything late.
Authorization. The bridge sees every event on the topic. Filtering by account key is also an authorization boundary: register sockets only under accounts the authenticated user may see, following per-channel authorization for WebSocket subscriptions.
Verification #
Watch the Kafka side and the WebSocket side together. On the Kafka side, the number of consumer groups reading the topic should equal the number of bridge nodes (broadcast layout) or one (affinity layout), and it must not change when users connect and disconnect:
kafka-consumer-groups.sh --bootstrap-server kafka-0:9092 --list | grep ws-bridge | wc -l
kafka-consumer-groups.sh --bootstrap-server kafka-0:9092 --describe --group ws-bridge-node-a # lag per partition
On the WebSocket side, measure produce-to-browser latency by stamping events at production and comparing in the client. Then test resume: disconnect a client, produce events for its account, reconnect with the last offset, and confirm the missed events arrive exactly once.
Operational checklist #
FAQ #
Can browsers consume Kafka directly? #
No. Kafka’s protocol is a binary TCP protocol for trusted clients, and exposing brokers to browsers would be neither practical nor safe. A bridge service consumes Kafka and serves browsers over WebSockets or Server-Sent Events.
Should each WebSocket connection have its own Kafka consumer? #
No. It causes constant consumer group rebalances and multiplies broker reads by the number of users. Consume once per bridge node and route to sockets in memory.
How do I replay missed events after a reconnect? #
Send the partition and offset with each event; on reconnect, read from the next offset for that partition, filter by the client’s key, and then switch to the live stream. Kafka’s retention defines how far back that works.
Is Kafka too slow for real-time UIs? #
Kafka’s end-to-end latency is typically milliseconds to tens of milliseconds, depending on producer batching (linger.ms) and consumer polling. For live dashboards that is usually fine; tune producer batching down for the most latency-sensitive topics.
Related #
- NATS vs Redis for WebSocket Fan-Out — a fan-out tier between Kafka and sockets.
- Outbox Pattern for WebSocket Events — getting events into Kafka reliably.
- Redis Streams vs Pub/Sub for WebSocket Fan-Out — a lighter-weight log.
- Disconnecting Slow WebSocket Consumers — protecting the bridge from slow clients.
Back to Redis Pub/Sub Fan-Out.