كل المقالات

Zatat: why we run our own WebSocket server

Zatat is a Pusher-protocol WebSocket server in Rust. It holds 10,000 connections on a four euro box where Laravel Reverb manages 1,000, and switching to it costs you a hostname rather than a rewrite.

Ryan Aulia5 دقائق قراءةهذه النسخة بالإنجليزية، لم تُترجم بَعد.

Creators is our platform for live streamers, and its overlays talk to the browser over WebSockets. A creator goes live, a few thousand people watch, and every quiz answer, spin result and chat message has to land on screen before the moment passes. That connection is not a nice-to-have on a live stream. It is the product.

For a long time the realistic options were: pay a hosted service per connection, or run one of the open-source servers and hope it holds. We took a third one and wrote our own. It is called zatat, it is written in Rust, and it is MIT licensed and public.

The name is زتات, Bahraini Arabic for "in a hurry". Which is either a joke about latency or about how it got built.

It is not a side project we are admiring from a distance. It is the server behind our overlays and our chat, in production, right now.

The interesting part is not the speed

It is the protocol.

zatat speaks the Pusher Channels protocol, the same wire format as hosted Pusher and Laravel Reverb. Not "inspired by". The actual frames, in the actual order, down to the error codes.

That one decision is what makes it usable. Our web app uses pusher-js, the ordinary client library, configured like this:

new Pusher(key, {
  wsHost: cfg.host,
  wsPort: cfg.port ?? 443,
  forceTLS: true,
  enabledTransports: ["ws", "wss"],
});

There is nothing zatat-specific in there. There is no zatat SDK. Point wsHost somewhere else and the same code talks to hosted Pusher, to Reverb, or to zatat, and none of them can tell the difference. The same is true on the backend for pusher-http-node, pusher-http-php, Laravel Echo, and the iOS, Android and Flutter SDKs.

Compare that with the alternative shape of this decision. Moving to Socket.IO, Centrifugo or Ably means a different client library, different auth, different channel semantics, and a rewrite of every component that subscribes to anything. You do not switch realtime providers at that point. You migrate to one, over a quarter, and you do it once.

Protocol compatibility turns that into a config change. You are not betting the codebase on a new server. You are pointing at a different host, and if it disappoints you point back.

The protocol is the same, so only the hostname changes.

The numbers

These are from the repo's own benchmarks, run on a Hetzner CX23: shared 2 vCPU, 4 GB RAM, Ubuntu 24.04. About four euros a month. Same box, same config, back to back against Laravel Reverb.

Scenario

zatat

Reverb

POST /events throughput

46,228 req/s

2,710 req/s

Average latency, same test

1.35 ms

23.5 ms

WebSocket ramp, target 10,000

10,000 / 10,000

1,016 / 10,000

Connections opened per second

1,720

173

Fan-out p50 / p95 / p99, 1,000 subscribers

71 / 83 / 84 ms

68 / 83 / 84 ms

Roughly 17× the HTTP ingest and 10× the concurrent connections, on a four euro box.

تجربة تفاعلية

شغّل التجربة: سقف الاتصالات على جهاز واحد

نفس الجهاز، ونفس الإعداد، وتشغيل متتابع على خادم Hetzner CX23 بمعالجَين و٤ غيغابايت. الأرقام مأخوذة من قياسات المستودع، بلا تقريب.

zatat0
Laravel Reverb0

الهدف ١٠٬٠٠٠ اتصال · zatat يفتح ١٧٢٠ اتصالًا في الثانية، وReverb ١٧٣.

There is also a detail in that run that says more than any of the ratios: after the connection ramp, the Reverb process exited on its own and had to be restarted before the next test could run. zatat served all three scenarios without a restart.

The row where they tie

Look at the last line of the table again. At 1,000 subscribers, fan-out latency is identical. 71ms against 68ms is noise.

That row matters more than the ones zatat wins, because it tells you what the wins actually mean. Broadcasting one message to a thousand sockets is bound by the network, not the server, and any competent implementation lands in the same place. Nobody is going to feel a faster fan-out.

What you feel is the ceiling. The question is not how fast a message reaches a thousand subscribers, it is how many subscribers you can hold at once before the box gives up. On this hardware that number is about 1,000 for Reverb and 10,000 for zatat, and for a live stream that is the difference between a big night working and a big night being the reason people remember your platform.

What you actually get

Feature parity is not a slogan here, it is a list. Public, private and presence channels. Cache channels. Private-encrypted channels over NaCl secretbox. Client events with member gating and rate limits. User authentication and server-to-user events. Watchlists. The full HTTP API including batch events and channel stats. Webhooks, HMAC-signed, with retry on transient failures.

Operationally it is one static binary and one config file. No runtime to install, no process manager fighting a PHP worker. Multi-tenant, so several apps live in one config with their own keys, rate limits and origin allow-lists. Redis pub/sub when you need more than one node, which also gets you cross-node presence rosters. Prometheus metrics behind a bearer token. TLS through rustls.

Why this is the right shape for us

Creators has a load pattern that punishes per-connection pricing. Almost nothing happens for hours, then one creator goes live and there are ten thousand sockets in ten minutes, all subscribed to a handful of channels, all needing the same event within the same second.

Per-connection pricing is exactly wrong for that shape. You pay for a peak you hit occasionally and cannot predict, and the bill arrives after the good night rather than before it. Running the server ourselves turns a variable cost into a fixed one, and the fixed one is a small VM.

It also means the thing between our creators and their audiences is code we can read. When a connection drops at the wrong moment, that is a stack trace and not a support ticket.

Trying it costs you a hostname

zatat is MIT licensed and public at github.com/Dokan-E-Commerce/zatat. One static binary, one config file, nothing to install alongside it.

If you are already on Pusher or Reverb, the experiment is small. Run it somewhere, point wsHost at it, and your existing client code connects without a line changed. Give it a day of real traffic and watch the graphs.

If it disappoints, you point the hostname back. That rollback is the entire risk, and it is why this is an afternoon rather than a quarter. Very little else in a stack lets you audition a critical piece and walk away with the codebase untouched.

zatat is built and maintained by Ahmed Ali, our CTO. If it saves you a migration, a star on the repo is a fair trade.

قيم هذا المقال 🙋🏻

تقييمك للمقال يساعدنا في تحسين جودة المقالات وتطويرها

يمكنك التقييم بعد 60 ثانية