zevOS

01Technology

Building an OCPP gateway that survives ten thousand chargers

Every charger is a long-lived stateful WebSocket. The architecture decisions that determine whether your platform holds at scale — and the three that we would make differently in hindsight.

ENzevOS Engineering · Platform engineering
15 July 2026 · 3 min read

An OCPP gateway is an unusual piece of infrastructure. Unlike an HTTP API, every client holds a persistent connection for months. Unlike a chat system, the messages are financially significant and cannot be dropped. And unlike almost anything else, the clients are embedded devices with idiosyncratic firmware that will reconnect in a tight loop if you let them.

Rule one: the gateway does no business logic

The strongest single decision we made was that the OCPP layer validates, normalises and emits — and nothing else. It does not write to the database, it does not calculate billing, it does not decide authorisation policy. It turns a protocol frame into a domain event and puts it on a stream.

The reason is not architectural purity. It is that the gateway must never block. A charger waiting on a CALLRESULT while your billing engine takes a database lock is a charger that will time out, disconnect and reconnect — and now you have a thundering herd on top of a slow query.

charger ──ws──▶ gateway ──event──▶ stream ──▶ workers ──▶ database
                  │                                    │
                  └── validate, normalise              └── billing, sessions,
                      respond immediately                  analytics, alerts

Rule two: consumers must be idempotent

Once events go through a stream with at-least-once delivery, every consumer will eventually process a duplicate. Reclaiming unacknowledged messages after a worker restart guarantees it. If your StopTransaction handler is not idempotent, that restart bills a driver twice.

  • Key every handler on a natural identifier — the transaction id, the message id — and make reprocessing a no-op.
  • Reclaim pending messages on consumer startup rather than leaving them stranded in the group.
  • Wrap event processing in a hard timeout. A consumer that hangs on a database call blocks the group forever, and the symptom looks like data loss rather than a hang.

Rule three: the database pool is the real limit

Long before you saturate CPU or network, you will exhaust database connections. Every service holding a generous pool "just in case" is the standard way to discover this at the worst moment. Connection exhaustion presents as silence — handlers stop making progress, no errors are logged, and the gateway looks healthy because it is not the thing that is stuck.

Keep pools small and explicit, monitor pool utilisation as a first-class metric alongside latency, and put a timeout on anything that acquires a connection.

Three things we would do differently

Version the event schema from day one

Events outlive the code that emits them. Adding a version field costs nothing on day one and saves a migration on day four hundred, when a consumer needs to handle both the old and new shape of a payload that has been sitting in a stream for a week.

Carry a correlation id everywhere

A driver complaint becomes a five-service investigation: the charging page, the API, the gateway, the stream, the worker. Without a correlation id threaded through all of them, the investigation is grep and hope. Adding it retroactively means touching every emit site.

Build the dead-letter queue before you need it

An event that fails processing repeatedly should go somewhere visible, not be retried forever or silently dropped. Without a dead-letter path, the first genuinely malformed payload from an unusual firmware version becomes an infinite retry loop that takes the consumer group with it.

What actually breaks at scale

Not throughput. OCPP is a low-volume protocol per charger — a heartbeat every minute, meter values every thirty seconds during a session. Ten thousand chargers is a few thousand messages a second, which is unremarkable.

What breaks is state: ten thousand live WebSocket sessions that must be routable for outbound commands, survive a rolling deploy, and be reconciled when a charger reconnects to a different instance than it left. Solve routing and reconnection properly and the rest is ordinary engineering.

architectureOCPPengineering

Get the next one by email

One email a month. Operations notes, unit economics and policy changes that affect your tariff.