Running a hosted WebSocket server for IoT devices
6 min readThe Eventeer team
A fleet of sensors, trackers or controllers needs somewhere to connect. A WebSocket is a natural fit: one long-lived connection per device, small JSON frames, and a way to push messages back down. The first prototype is easy. A few dozen lines of Node or Elixir and you are receiving temperature readings.
The trouble starts when the prototype meets real devices on real networks. This post lists what a production WebSocket server for IoT devices has to do, how much of it people underestimate, and what you get from a hosted device listener instead. If you would rather build it, the list is also a decent spec.
What you would build yourself
Authentication per device
An open endpoint is fine for a demo and a problem afterwards. You need a way to tell which device is connecting and refuse the rest. Typical designs: a shared token for the whole fleet (simple, but one leaked firmware image exposes everything) or a token per device (better, and you need to issue, store, hash and revoke them).
Browsers cannot set an Authorization header on a WebSocket, but devices usually can. If you also support web clients, you need the token in a query parameter as a fallback, and you must make sure it does not end up in logs.
Acknowledgements
TCP tells the device the bytes left its buffer. It does not tell the device that your server stored the reading. Without an application-level ack, a device that loses connectivity mid-send does not know what to resend. You need a per-message reference from the device, an ack from the server after the write is durable, and a retry rule on the device.
Presence: who is online
“Is pump 17 connected?” sounds trivial. The answer needs a map of device to connection, updated on connect and disconnect, and it has to survive the case where a node dies without any socket closing cleanly. That last case is the hard one: you need heartbeats and a sweeper that marks devices offline when they go quiet. You also need last_seen timestamps, because “offline since 03:12” is the thing support actually asks for.
Reconnects and duplicates
Mobile and cellular links drop all the time. Devices reconnect, often before the server noticed the old connection died. Now there are two sockets for one device. You must decide which one wins and close the other. Both sides also need backoff with jitter, or a network outage ends with every device reconnecting in the same second.
Downlink
Sending a command to a device (“reboot”, “change interval”) requires finding the right socket. On one server that is a dictionary lookup. On several servers it means a pub/sub layer so a request that lands on node A can reach a socket held by node B. You also have to define what happens when the device is offline: queue, drop, or tell the caller.
Scaling and limits
Each connection holds memory and a file descriptor. You need to cap payload sizes, rate-limit noisy devices, and decide what happens at a connection ceiling. Add TLS termination, deploys that do not disconnect the entire fleet at once, and metrics on connection counts.
Storage and visibility
Once data is flowing, someone wants to look at it. Where do events live, for how long, and how do you filter “everything from device pump-17 yesterday”? That is a database, a retention job and a UI.
None of these are exotic. Together they are a service you now operate.
What a device listener covers
An Eventeer device listener is a hosted WebSocket endpoint that handles the list above. You create one through the API or the app:
curl -X POST https://app.eventeer.io/api/v1/listeners \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"pumps","type":"device","auth_mode":"token"}'
Then issue a token bound to one device:
curl -X POST https://app.eventeer.io/api/v1/listeners/lst_7Qp2xK9vLmN3rT5wY8zA1b/credentials \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"kind":"device_token","device_id":"pump-17"}'
The plaintext secret is returned once. A device token is bound to one device, and the device_id the device connects with must match it. Credentials can be revoked individually.
Connecting
A device opens a WebSocket to /in/d/<listener_key> with its device_id:
GET /in/d/lst_7Qp2xK9vLmN3rT5wY8zA1b?device_id=pump-17
Authorization: Bearer dt_...
If the device cannot set headers, ?token= works too. Frames are JSON text. The device sends events with a reference of its choosing:
{"type":"event","ref":"r1","data":{"temp":21.4}}
and gets a per-event ack once the event is stored:
{"type":"ack","ref":"r1","id":"0192f3a4-7b1e-7c55-9a3e-5d2f8b6c1a3f"}
On failure it gets {"type":"error","ref":"r1","code":"payload_too_large"} instead. A device can add an idempotency_key to an event so a resend after a dropped ack does not create a second copy. So the device-side rule is simple: keep each event until you see its ack, resend on reconnect with the same key.
Presence and heartbeats
On connect the device is marked online; on disconnect it is offline with last_seen_at set. The server pings every 30 seconds by default and closes a connection with no traffic for twice that. A device whose node died is marked offline by a sweeper after about a minute plus three heartbeats of silence. You can read the state over the API:
curl https://app.eventeer.io/api/v1/listeners/lst_7Qp2xK9vLmN3rT5wY8zA1b/devices \
-H "Authorization: Bearer $EVENTEER_KEY"
Each device comes back with status, last_seen_at and last_connected_at.
Reconnects
If a device connects while an older connection for the same device_id is still open, the older one is closed with code 4409, on whichever node holds it. The client’s rule: on 4409 check whether another instance of your own firmware is running; on anything else, reconnect with exponential backoff and jitter. Other close codes tell you why: 4401 bad credentials, 4402 plan required or spend cap reached, 4423 listener paused, 4429 sustained rate limiting.
Downlink
To send a command, call the API with the device’s id:
curl -X POST https://app.eventeer.io/api/v1/devices/9b2e4f61-0c3d-4a8b-b7e5-1d6f2a9c8e30/messages \
-H "Authorization: Bearer $EVENTEER_KEY" \
-H "Content-Type: application/json" \
-d '{"data":{"cmd":"reboot"}}'
If the device is connected, it receives {"type":"message","id":"msg_…","data":{"cmd":"reboot"}} and you get 202. If not, you get 409 device_offline. Downlink is best-effort: messages are not stored or queued, so a command that must not be lost needs to be re-sent by your application, or the device should fetch pending work when it connects.
Storage, metrics and alerts
Every event is stored for your plan’s retention and visible in the event explorer, filterable by device. From there you can build metrics over a field such as $.temp and dashboards on top. You can also set an alert that fires when a device or a whole listener has been quiet for N minutes. The monitoring and alerts use case page covers that side, and this post walks through a quiet-source alert with real requests.
What you still own
Hosting the socket does not design your protocol. You choose the payload shape, how the device buffers while offline, and what a command means. Devices must handle the ack and reconnect rules above. And downlink is not a durable command queue.
Limits apply too. Concurrent connections count against the plan (devices, streams and outbound sources share the same ceiling), and connection minutes are metered. Check pricing against your fleet size before you commit: a fleet of always-on devices is mostly a connection-minutes question.
Build or host?
Build it if the connection layer is your product, if you have unusual protocol requirements (binary framing, MQTT-only hardware), or if you already run infrastructure for this. Host it if the sockets are plumbing and your product is what you do with the data.
The details of the device flow are on the IoT devices use case page, and every endpoint above is in the API reference.