One real-time chat app, built three ways — to study how the actor model maps onto different language/runtime ecosystems. All implementations speak the same WebSocket protocol, persist to the same kind of Convex backend, authenticate with the same Clerk setup, and are driven by the same React frontend, so you can run any one behind an identical UI.
| Implementation | Actor runtime | HTTP/WS | Port | Status |
|---|---|---|---|---|
| go-actor-chat/ | anthdm/hollywood |
echo + coder/websocket |
8080 |
working |
| rust-actor-chat/ | hand-rolled tokio (mpsc/oneshot) |
axum |
8090 |
working |
| zig-actor-chat/ | InkList |
httpz + websocket.zig |
8100 |
working |
Each subproject has its own detailed README and run guide. Go and Rust ship self-contained
convex/ + web/; the Zig server reuses those (point VITE_WS_URL_ZIG at :8100).
All servers are stateless real-time relays. Ephemeral state — room presence and typing indicators — lives in the actors; durable state — users, rooms, memberships, messages — lives in Convex. The React client reads message history straight from Convex (reactive queries) and uses the WebSocket only for sending, presence, and typing.
Browser (React + Vite)
│ │
│ Clerk │ WebSocket (?token=<Clerk JWT>)
│ + Convex ▼
│ reactive ┌──────────────────────────────────────────┐
│ queries │ actor server (Go :8080 / Rust :8090 / │
│ │ Zig :8100) │
│ │ │
│ │ validate Clerk JWT → resolve user │
│ │ per-connection writer (owns socket) │
│ │ per-room actor (presence/typing)│
│ │ lazy room registry │
│ └───────────────────┬──────────────────────┘
▼ │ HTTP (/api/query, /api/mutation)
Clerk ▼
Convex (users, rooms, memberships, messages)
Three actor roles in all three: a per-connection writer that owns socket writes (so they serialize through one mailbox), a per-room actor that single-owns presence and typing and broadcasts updates, and a registry that spawns rooms lazily and evicts them when idle. The blocking socket read runs off the actor in all three.
One JSON envelope per message, discriminated by type.
- Inbound:
join,leave,send,typing_start,typing_stop,ping - Outbound:
ack,error,presence_update,typing_update,pong(Rust and Zig also send ahelloframe on connect)
| Concern | go-actor-chat | rust-actor-chat | zig-actor-chat |
|---|---|---|---|
| Actor runtime | anthdm/hollywood |
hand-rolled tokio::mpsc |
InkList |
| HTTP / WebSocket | echo + coder/websocket |
axum |
httpz + websocket.zig |
| Wire protocol | one Frame struct |
serde tagged enums |
one Frame struct |
| Request/response | engine.Request |
oneshot |
func payloads / threads |
| Room registry | mutex map + engine registry | Arc<Mutex<HashMap>> |
mutex map + actor ids |
| JWT / JWKS | golang-jwt + keyfunc |
jsonwebtoken |
std RSA + JWKS fetch |
| Convex client | net/http |
reqwest |
std.http.Client |
| Idle eviction | SendRepeat + Poison |
select! + interval |
InkList sendEvery |
The point of the repo: see the same actor shapes expressed through a framework (Go), hand-rolled async primitives (Rust), and a Zig actor library (InkList).
See each subproject's README for full setup (Convex deploy + Clerk JWT template + env):
Because they speak the same protocol, you can point the React frontend at any
backend from the implementation picker shown right after sign-in (Go :8080,
Rust :8090, Zig :8100). Choice is remembered for the signed-in session (sidebar
badge to switch); signing out clears it so the picker returns. Override URLs with
VITE_WS_URL_GO / VITE_WS_URL_RUST / VITE_WS_URL_ZIG.
Each backend is a separate process — start the ones you want to compare:
# Go :8080
(cd go-actor-chat && make run)
# Rust :8090
(cd rust-actor-chat && cargo run)
# Zig :8100
(cd zig-actor-chat && zig build run)
# Frontend (any one copy is enough)
(cd rust-actor-chat/web && bun dev)actor-chat/
├── go-actor-chat/ Go + Hollywood implementation (server, convex/, web/)
├── rust-actor-chat/ Rust + tokio implementation (server, convex/, web/)
├── zig-actor-chat/ Zig + InkList implementation (server; reuse Go/Rust web)
└── README.md (this file)
MIT (or your choice).