Choose the channel
Keep HTTP as the foundation
Use normal requests for initial state and commands, then reserve the live channel for changes.
A live socket should complement HTTP, not replace it. Our status board loads a full snapshot over HTTP, posts operator commands over HTTP, and uses the stream only for changes after that.
If you push every read and write through one long-lived connection, you lose inspectable routes, standard caching, and retry semantics you already know.
Three routes, three jobs
GET /api/incidents -> full snapshot for first paint
POST /api/incidents/:id/ack -> operator command with normal HTTP errors
GET /events -> SSE stream of incident.created and incident.updated
First visit hits the snapshot route. The response includes every open incident and a latestEventId:
{
"incidents": [
{ "id": 41, "title": "API latency", "status": "investigating" }
],
"latestEventId": "evt_84"
}
The browser paints the board, then opens SSE starting after evt_84. If the stream gaps, the same snapshot route repairs state.
Commands stay on HTTP
Operator acknowledgement is a POST, not a mystery frame on the socket:
await fetch('/api/incidents/41/ack', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: 'Restarted worker 2' }),
})
You get status codes, logs, and idempotency keys for free. I keep privileged actions on HTTP even when the operator also has a WebSocket open for push notifications.
Name the repair path
Write down which route fixes a missed update. For us, GET /api/incidents is the source of truth. The event stream is an optimization.
Hand your route list to another developer without explaining verbally. If they cannot tell how the board recovers, split responsibilities further.
Idempotency on POST
Operator commands use a header:
await fetch('/api/incidents/41/ack', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': commandId,
},
body: JSON.stringify({ note: 'Failover complete' }),
})
Retry the same key after a timeout. The server should answer once with 201 and later with the same body, not create duplicate audit rows.
The event channel should never be the only place an operator action is recorded. If the POST succeeds and the socket dies before the ack frame, HTTP logs still prove the command landed.
Try this on your own project: define one snapshot route, one command route, and one event channel, then document which route wins when they disagree.
Lesson completed