Queues vs Streams vs WebSockets: When to Use Each in Real-Time Systems

12 min readSystem Design

If you have ever built anything "real-time," you have probably stared at a screen wondering whether to reach for Redis Pub/Sub, Kafka, SSE, WebSockets, or just keep polling an API until something happens. I have been there more times than I want to admit.

The confusing part is that all of these tools move data around. They all feel like "messaging." But they are built for different jobs, and the moment you use the wrong one, you start fighting it. WebSockets are fast but fragile. Queues are reliable but slow. Streams are flexible but complicated.

This post is my attempt to explain the difference without the jargon. If you are trying to decide which tool to use for your next feature, start here.

The thing nobody tells you about "real-time"

"Real-time" does not mean one thing.

A live chat needs updates in milliseconds. A dashboard that refreshes a chart every few seconds feels real-time to the user. An email that lands five minutes after an action still feels instant because nobody is waiting for it. These are all real-time in the loose sense, but they need completely different tools.

The real question is not "do I need real-time?" It is three questions:

  • How fast does this need to reach someone?
  • Which direction does the data need to flow?
  • What happens if a message gets lost?

Once you answer those, the tool almost picks itself.

Message queues: leave a task and move on

A queue is basically a to-do list that your application keeps for itself.

Imagine you run a restaurant. A customer orders food. You do not make the cook stop cooking the current dish, take the order, cook it, and then come back. You write the order on a ticket and stick it on a rail. A cook grabs it when they are ready. If a cook burns the dish, they start over. If the kitchen is slammed, orders pile up and get handled in order.

That is a queue.

In software, a producer puts a message into the queue and immediately moves on. A consumer picks it up later, processes it, and tells the queue it is done. If the consumer crashes, the message stays in the queue and gets retried. Tools like RabbitMQ, BullMQ, AWS SQS, and Celery work this way.

Queues are great for:

  • Sending emails
  • Processing uploads
  • Resizing images
  • Running background reports
  • Charging a payment
  • Anything where the user does not need an instant answer

They are not great when someone is staring at a loading spinner. If you need a UI update in under a second, a queue is probably too slow.

The big idea: queues decouple work. The thing that creates the task does not have to wait for the thing that does the task. That makes your system more reliable, not faster.

image

Here is what a queue looks like in code, using BullMQ on Redis:

1import { Queue, Worker } from 'bullmq';
2import Redis from 'ioredis';
3
4const redis = new Redis();
5const emailQueue = new Queue('emails', { connection: redis });
6
7// in your API route
8await emailQueue.add('send-welcome', { userId: '123' });
9
10// in a separate worker process
11const worker = new Worker('emails', async (job) => {
12  await sendEmail(job.data.userId);
13}, { connection: redis });

The API does not wait for the email to send. The worker handles it later, with retries if it fails.

Event streams: a timeline everyone can read

An event stream is like a timeline or a newspaper. Events get written down in order, and anyone who cares can read them.

Think of it this way. A queue is a to-do list. An event stream is a diary. If a new employee joins your company and needs to know everything that happened in the last month, you can hand them the diary and they can catch up. With a queue, that is impossible, because the queue deletes tasks after they are done.
image

Tools like Kafka, Redis Streams, and NATS JetStream keep events around for a while. Multiple services can read the same events at their own speed. One service might process orders right away. Another might build an analytics report every hour. A third might replay the last week of events because it just got a bug fix.

Event streams are great for:

  • Event sourcing
  • Audit logs
  • Analytics pipelines
  • Feeding the same data to many systems
  • Anywhere you need to replay or reprocess events

They are not great for asking a question and waiting for an answer. Kafka is not a request-response system. You do not send a message to Kafka and wait for a reply.

Here is a tiny Kafka example with kafkajs. A producer writes an order. A consumer in the analytics group reads it and updates a report:

1import { Kafka } from 'kafkajs';
2
3const kafka = new Kafka({ brokers: ['localhost:9092'] });
4const producer = kafka.producer();
5await producer.connect();
6
7await producer.send({
8  topic: 'orders',
9  messages: [{ value: JSON.stringify({ orderId: '789', total: 42 }) }],
10});
11
12const consumer = kafka.consumer({ groupId: 'analytics' });
13await consumer.connect();
14await consumer.subscribe({ topic: 'orders', fromBeginning: false });
15
16await consumer.run({
17  eachMessage: async ({ message }) => {
18    const order = JSON.parse(message.value.toString());
19    await updateAnalytics(order);
20  },
21});

The producer does not wait for the analytics service. Multiple consumer groups can read the same orders topic at their own speed.

Server-Sent Events: the simple way to push to a browser

Before we get to WebSockets, there is one more thing worth knowing about. Server-Sent Events, or SSE, is a way for a server to keep an HTTP connection open and slowly send data to a browser.

The browser makes a normal GET request. The server says, "Okay, I am going to keep this response open," and starts writing events as text. The browser reads them as they arrive. If the connection drops, the browser automatically reconnects and tells the server the last event it saw, so the server can resume from there.

That is it. No special protocol. No custom handshake. It works over normal HTTP.

This is the reason ChatGPT and Claude stream their responses word by word. They use SSE. It is perfect when the server needs to push text updates to a browser and the client just needs to listen.

SSE is great for:

  • Live dashboards
  • Log tails
  • Stock tickers
  • LLM token streaming
  • Notifications that do not need a reply

The catch is that SSE only goes one direction. The client cannot send data back over the same connection. It also only handles text, not binary data like images or audio. And some proxies will try to buffer the response, which breaks the whole point, so you have to turn buffering off.

Here is the simplest useful SSE server in Express:

1import express from 'express';
2
3const app = express();
4
5app.get('/events', (req, res) => {
6  res.setHeader('Content-Type', 'text/event-stream');
7  res.setHeader('Cache-Control', 'no-cache');
8  res.setHeader('X-Accel-Buffering', 'no');
9  res.flushHeaders();
10
11  const interval = setInterval(() => {
12    res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
13  }, 1000);
14
15  req.on('close', () => clearInterval(interval));
16});
17
18app.listen(3000);

And the browser side is just as small:

1const source = new EventSource('/events');
2source.onmessage = (event) => {
3  console.log(JSON.parse(event.data));
4};

EventSource reconnects automatically if the connection drops, and it sends Last-Event-ID so the server can resume from the right place.

WebSockets: a phone call between browser and server

A WebSocket is a persistent connection between a browser and a server. Both sides can send messages at any time. It is the closest thing the web has to a phone call.

The connection starts as a normal HTTP request, then upgrades. After that, messages fly back and forth with very little overhead. There is no repeated HTTP header overhead. There is no need to open a new connection for every message. That makes WebSockets fast.

But a phone call has downsides too. If the connection drops, someone has to call back. The browser WebSocket API does not automatically reconnect, so you have to write that logic yourself. If the user has two tabs open, they might connect to two different servers, so you need a backplane to route messages between servers. Corporate proxies and load balancers sometimes kill idle WebSocket connections after a minute, so you need heartbeats.

WebSockets are great for:

  • Chat
  • Multiplayer games
  • Collaborative editors
  • Live code sessions
  • Anything where both sides need to talk quickly and unpredictably

They are overkill for a dashboard that just needs occasional updates. If the data mostly flows from server to client, SSE is usually simpler. If the data is mostly one request and one response, just use normal HTTP.

Here is a minimal WebSocket server with the ws package:

1import { WebSocketServer } from 'ws';
2
3const wss = new WebSocketServer({ port: 8080 });
4
5wss.on('connection', (ws) => {
6  ws.on('message', (data) => {
7    ws.send(`echo: ${data}`);
8  });
9
10  // heartbeat to keep proxies from killing the connection
11  const interval = setInterval(() => ws.ping(), 30000);
12  ws.on('close', () => clearInterval(interval));
13});

The client connects and can send or receive at any time:

1const ws = new WebSocket('ws://localhost:8080');
2ws.onopen = () => ws.send('hello');
3ws.onmessage = (event) => console.log(event.data);

In a real app, you would add reconnection logic on the client and a Redis Pub/Sub backplane on the server so messages can reach users on different nodes.

How I actually decide

image

I usually run through a few quick questions.

Is a human waiting and watching?

If yes, and the experience gets annoying above a few hundred milliseconds, you probably want WebSockets or SSE. If the user is not actively waiting, a queue is fine.

Which way does the data go?

  • Mostly server to client: SSE is probably enough.
  • Client to server only: a normal HTTP POST is probably enough.
  • Both directions, whenever: WebSockets.
  • Between services, many readers: an event stream.

Can I afford to lose a message?

If losing a message means a payment did not process or an email did not send, use a durable queue or stream. If losing a message just means a typing indicator blinks, you can use WebSockets or Redis Pub/Sub.

How many things need this data?

  • One consumer per message: use a queue.
  • Many consumers, at different speeds: use a stream.
  • One browser client: use WebSockets or SSE.

Here is a quick comparison table you can save:

ToolDirectionSpeedKeeps messages?Best for
QueueOne producer, one consumerSeconds to minutesYes, with retriesBackground jobs, async work
Event streamOne producer, many consumersMilliseconds to secondsYes, for replayEvent sourcing, analytics, fan-out
SSEServer to browserMilliseconds to secondsReconnects and resumesLive dashboards, LLM streaming
WebSocketBoth waysMillisecondsOnly while connectedChat, games, collaboration

Mistakes I have made and seen

Using WebSockets for background work. I once saw a team send video processing tasks over WebSockets. The moment the user closed their laptop, the task vanished. Background work belongs in a queue.

Polling a queue from the frontend. If the browser is asking the backend every second "is it done yet?" you probably wanted SSE or WebSockets. Queues are not a push mechanism for UIs.

Using Kafka for request-response. Kafka is amazing for events. It is terrible for "call this service and wait for an answer." Use HTTP or gRPC for that.

Using WebSockets when SSE would work. If your feature is "the server pushes updates to the browser" and the user never needs to send anything back over the same connection, start with SSE. It is simpler, reconnects automatically, and works over normal HTTP.

Forgetting the backplane. A single-server WebSocket demo is easy. Scaling to multiple servers is where people get burned. You need Redis Pub/Sub, NATS, or a managed service to route messages to the right server.

A real example: how a chat app uses all three

The best real-time systems do not pick one tool. They use each one for what it does well.

image

Take a chat app like Slack.

Live messages and typing indicators use WebSockets. Alice sends a message, the server routes it to Bob's WebSocket server through a backplane, and Bob sees it. Fast, bidirectional, exactly what WebSockets are for.

Email digests and push notifications use a queue. If Bob is offline, the app does not panic. It adds a job to the queue, and a worker sends the email later with retries. Reliable and decoupled.

Message history, search, and analytics use an event stream. Every message is written to a stream. A search indexer reads it and adds it to Elasticsearch. An analytics service counts it. An audit service archives it. Each service goes at its own pace and can replay events if needed.

If the chat app had a read-only live feed, SSE might be enough instead of WebSockets. The point is that the right system uses multiple tools, not one hammer for every nail.

FAQ

Can I use Redis Pub/Sub as a queue?

Only if you do not care about delivery. Pub/Sub is fire-and-forget. If no one is listening, the message disappears. For reliable work, use BullMQ, Redis Streams, or a proper queue.

When should I pick SSE over WebSockets?

If data only goes from server to client, start with SSE. It is simpler and reconnects for free. Move to WebSockets when you need two-way messaging or binary data.

Is Kafka a queue?

Kind of, but not really. Kafka stores events in a log and lets multiple consumers read them. The big difference is that events stay in the log for a while and can be replayed. A traditional queue deletes messages after one consumer handles them.

Can I stream to a browser without WebSockets?

Yes. SSE is built for exactly that. It is simpler and more proxy-friendly than WebSockets for one-way streaming.

How do I scale WebSockets?

You need a backplane so servers can find where a user is connected. Redis Pub/Sub works for smaller setups. For large scale, look at NATS or managed services like Ably or Pusher.

Wrapping up

There is no single best tool for real-time systems. The best tool depends on how fast the data needs to move, which way it flows, and what happens when something breaks.

Queues handle work that can wait. Event streams handle data that many systems need over time. SSE handles one-way updates to a browser. WebSockets handle fast, two-way conversations. Most real apps end up using at least two of them.

The next time someone on your team says "let's use WebSockets" or "let's use Kafka," ask what they actually need. The answer is rarely the tool itself. It is usually a specific latency, direction, and durability problem.

Get those three things right, and the choice becomes obvious.