WebSocket vs MQTT vs Server-Sent Events: Real-Time IoT Protocol Deep Dive
Every IoT system needs real-time communication, but "real-time" means different things at different layers of your stack. Your temperature sensor talking to the cloud has radically different requirements than your browser dashboard talking to your backend. Picking the wrong protocol at any layer means wasted bandwidth, battery drain, or a dashboard that lags by five seconds when a machine alarm fires.
This guide cuts through the confusion. We'll compare WebSocket, MQTT, and SSE on the dimensions that actually matter in production IoT — not theoretical benchmarks, but the tradeoffs we navigate in real deployments.
The Core Distinction: Who Talks to Whom
Before comparing protocols, establish the communication model at each layer:
| Layer | Sender | Receiver | Pattern | |---|---|---|---| | Device → Cloud | ESP32/STM32 | AWS IoT Core / Broker | Publish (mostly) | | Cloud → Device | Backend / Rules | ESP32/STM32 | Subscribe (commands) | | Backend → Browser | Node.js backend | React dashboard | Push | | Browser → Backend | React dashboard | Node.js | Request / command |
MQTT owns the device layer. WebSocket and SSE compete for the browser layer. Understanding that distinction saves weeks of architectural regret.
MQTT: Purpose-Built for Constrained Devices
MQTT was designed in 1999 for satellite SCADA links — high latency, low bandwidth, unreliable connections. That heritage makes it perfect for embedded IoT.
Why MQTT wins on the device side:
// ESP32: MQTT telemetry with LWT
void setupMQTT() {
client.setServer(MQTT_BROKER, 8883); // Last Will: published automatically if we disconnect ungracefully
client.connect(
DEVICE_ID,
nullptr, nullptr, // username/password
"devices/sensor01/status", // LWT topic
1, // LWT QoS
true, // LWT retain
"{"online": false}" // LWT payload
);
// Publish online status
client.publish("devices/sensor01/status",
"{"online": true}", true); // retained
// Subscribe to command topic
client.subscribe("devices/sensor01/cmd", 1);
}
void publishTelemetry(float temp, float humidity) {
char payload[128];
snprintf(payload, sizeof(payload),
"{"t":%.2f,"h":%.2f,"ts":%lu}",
temp, humidity, millis()
);
// QoS 0 for telemetry — fire and forget, lowest overhead
client.publish("devices/sensor01/telemetry", payload, 0);
}
MQTT limitations for the browser: Most browsers cannot open raw TCP connections. MQTT-over-WebSocket exists, but you lose half the advantage — you're running MQTT framing inside WebSocket framing inside TLS. For browser clients, SSE or WebSocket is the better abstraction.
WebSocket: Full-Duplex for Interactive Dashboards
WebSocket upgrades an HTTP connection to a persistent, full-duplex TCP channel. Both client and server can send messages at any time with minimal overhead after the handshake.
When to choose WebSocket:
// Node.js backend: bridge MQTT ↔ WebSocket
import { WebSocketServer } from 'ws'
import mqtt from 'mqtt'const mqttClient = mqtt.connect('mqtts://your-broker:8883', {
cert: fs.readFileSync('backend.crt'),
key: fs.readFileSync('backend.key'),
ca: fs.readFileSync('rootCA.pem'),
})
const wss = new WebSocketServer({ port: 8080 })
// Track which WS clients are watching which devices
const subscriptions = new Map>()
mqttClient.on('message', (topic, payload) => {
// topic: "devices/sensor01/telemetry"
const deviceId = topic.split('/')[1]
const watchers = subscriptions.get(deviceId)
if (watchers) {
const message = JSON.stringify({
deviceId,
data: JSON.parse(payload.toString()),
receivedAt: Date.now(),
})
watchers.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) ws.send(message)
})
}
})
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString())
if (msg.type === 'subscribe') {
// Client wants live data for a device
if (!subscriptions.has(msg.deviceId)) {
subscriptions.set(msg.deviceId, new Set())
mqttClient.subscribe(devices/${msg.deviceId}/telemetry)
}
subscriptions.get(msg.deviceId)!.add(ws)
}
if (msg.type === 'command') {
// Client sending a command to a device — forward via MQTT
mqttClient.publish(
devices/${msg.deviceId}/cmd,
JSON.stringify(msg.payload),
{ qos: 1 }
)
}
})
ws.on('close', () => {
// Clean up subscriptions for this client
subscriptions.forEach((watchers, deviceId) => {
watchers.delete(ws)
if (watchers.size === 0) {
subscriptions.delete(deviceId)
mqttClient.unsubscribe(devices/${deviceId}/telemetry)
}
})
})
})
WebSocket overhead: The opening handshake is HTTP (several hundred bytes). After that, each frame has 2–14 bytes of overhead. For high-frequency telemetry streams, this is negligible.
Server-Sent Events: The Underrated Option
SSE is an HTTP/1.1 feature where the server holds the response open and streams events indefinitely. The browser's EventSource API handles reconnection automatically.
When SSE beats WebSocket:
// Next.js API route: SSE endpoint for live device data
import type { NextRequest } from 'next/server'export async function GET(req: NextRequest) {
const deviceId = req.nextUrl.searchParams.get('deviceId')
const stream = new TransformStream()
const writer = stream.writable.getWriter()
const encoder = new TextEncoder()
const sendEvent = (event: string, data: unknown) => {
writer.write(
encoder.encode(`event: ${event}
data: ${JSON.stringify(data)}
`)
)
}
// Subscribe to MQTT via your internal broker client
const unsubscribe = mqttBridge.subscribe(
devices/${deviceId}/telemetry,
(payload) => {
sendEvent('telemetry', payload)
}
)
req.signal.addEventListener('abort', () => {
unsubscribe()
writer.close()
})
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
SSE limitations: Unidirectional only — browser cannot send data over the SSE connection. For commands, you issue a separate HTTP POST. This is actually a feature for many dashboards — it forces clean separation between read (SSE) and write (REST).
Protocol Comparison Table
| Dimension | MQTT | WebSocket | SSE | |---|---|---|---| | Transport | TCP (or TCP+WS) | TCP (HTTP upgrade) | HTTP/1.1 or HTTP/2 | | Direction | Bidirectional | Bidirectional | Server → Client only | | Browser native | No (needs lib) | Yes | Yes (EventSource) | | Reconnection | Client must implement | Client must implement | Automatic | | Message ordering | QoS-dependent | Ordered | Ordered | | Overhead per message | 2 bytes minimum | 2–14 bytes | ~6 bytes (framing) | | Best for | IoT devices | Interactive dashboards | Read-only dashboards | | Load balancer friendly | Needs TCP pass-through | Needs WS support | Yes (standard HTTP) |
The Architecture We Actually Deploy
In production, we use all three — at different layers:
ESP32 ──MQTT/TLS──► AWS IoT Core
│
IoT Rules Engine
│
Node.js Backend ◄──── REST (commands from dashboard)
│
SSE or WebSocket ──► React Dashboard
The mistake we see most often: teams use WebSocket for everything, including the device connection, because they're comfortable with it. On a 4G-connected sensor reporting every 10 seconds, WebSocket works fine. On an NB-IoT sensor on a 200kbps link reporting every 30 seconds, the WebSocket handshake overhead adds meaningful latency and the lack of QoS means you drop readings during brief outages. MQTT handles both gracefully.
Need help? [Contact Code Caracal](/contact) — we've shipped these systems for clients across 15+ countries.