Self-hosted encrypted chat
A self-hostable chat app with end-to-end encrypted messages over WebSockets, roughly Discord-shaped. Ran through 2025, never went public, and is getting rewritten from scratch in 2026 rather than resumed.
- End-to-end encrypted messages — the same constraint Yunoa is built around, applied to something with more than one participant.
- Realtime delivery over WebSockets: presence, ordering, reconnection, and messages that arrive while you're offline.
- Self-hostable — anyone can run their own instance, rather than there being one server everybody has to trust.
- A companion mobile app that stalled, for the second time across my projects.
- Untyped JSON on the wire and no protocol above the raw socket — the two decisions that made it cheaper to restart than to fix.
- Never went public. Being rewritten from scratch in 2026, schema first this time.
outcome — Never went public. Nothing broke — it just got too expensive to change, until starting over was cheaper than fixing it.
- TypeScript
- WebSockets
- React-Native
Roughly Discord-shaped: servers, channels, realtime messages, a mobile app. The part that made it worth building was that the messages are end-to-end encrypted and that you can run your own instance instead of trusting mine.
It ran through 2025, never went public, and I'm rewriting it from scratch in 2026 rather than picking it back up.
self-hostable, not federated#
Worth being precise, because the words get used loosely and the difference is the whole architecture.
Anyone can stand up their own instance. What instances can't do is talk to each other — there's no protocol between them, so a user on one can't message a user on another. That's self-hosting. It is not federation, and it isn't Matrix. Federation is a much harder problem than running the same software in more than one place, and calling this decentralised would be claiming I solved the hard version when I solved the easy one.
The easy one is still worth having. The default for a chat app is one company's server holding everyone's messages, and "run it yourself" is a real answer to that even without instances interoperating.
encryption with more than one person in the room#
Yunoa is built around the same constraint — the server can't read the contents — and it's much easier there, because a diary has one reader and one writer and they're the same person.
Chat breaks that. A message has to be readable by everyone in a channel and nobody else, the set of people in that channel changes, and each new device someone signs in on is another thing that needs to be able to read history it wasn't present for. Every feature that seems trivial gets hard once the server is holding ciphertext: search across your own messages, a new phone, a notification that can say who it's from.
Yunoa's sync problem is the single-user version of this and I still haven't fully answered it. This was the same question with a group in the middle of it.
the realtime half#
Under the encryption is an ordinary hard problem: WebSockets, and everything that goes wrong with a connection people expect to be permanent.
Connections drop constantly — phones sleep, networks change, laptops close. Messages sent while a client was gone have to arrive when it comes back, in the right order, exactly once. Presence has to distinguish "closed the app" from "went into a tunnel", and it only finds out about the second one by waiting. None of that is visible when it works and all of it is the product when it doesn't.
the mobile app, again#
There was a React Native companion app and it stalled.
That's twice. School Tinder had a mobile app that didn't get far either, and the honest read is that both times I treated the second platform as a port of a thing that already existed rather than as most of the remaining work. It isn't a port. It's a client with its own storage, its own notification path, its own background behaviour, and — here — its own copy of the key handling.
Yunoa is React Native and is the one I'm actually shipping. Whether that's the lesson landing or just a project small enough to finish, I'll know when it's out.
why it's a rewrite and not a resumption#
It never went public, and nothing dramatic happened to it. No outage, no incident, nothing broke. It just got progressively more expensive to change, until starting over was cheaper than adding the next thing.
Two decisions did most of that, and they compound.
no schema#
Messages were untyped JSON in both directions. A client parsed whatever arrived and read fields off it, which meant the definition of a message lived in the head of whoever last wrote a handler.
You can see the damage in the debugging tool I ended up needing:
logIncoming(event: MessageEvent) {
if (!this.enabled) return;
try {
const data = JSON.parse(event.data);
this.logs.push({
direction: "in",
type: data.type ?? "unknown",
timestamp: Date.now(),
payload: data,
});
} catch {
console.warn(`invalid message: ${event.data}`);
}
}data.type ?? "unknown" is the whole problem in one line. The logger is
guessing. It has no idea what shapes are legal, so it reaches for a field that
might not be there and shrugs if it isn't — and the catch exists because
"this might not even be JSON" was a real possibility at runtime.
The tool itself was a good instinct. Building an observability layer for your own protocol is the right move, and it's the only reason I could see what was happening on the wire at all. But I should have noticed what it implied: you don't need a tool to discover the shape of your own messages if the shapes are written down anywhere. I built the instrument instead of fixing the thing it was measuring.
For an encrypted app it's worse than untidy. With no declared schema there's no crisp line between what's ciphertext and what's metadata the server legitimately needs to route on — and that line is the security model. You can't audit a boundary that was never drawn.
a raw socket and no protocol on top of it#
The transport was the standard WebSocket and nothing else. No message IDs, no
acks, no ordering guarantees, no resend queue — just send and hope.
That pushes connection state into every call site. Everything that wanted to send a message first had to check whether it could:
send(type: string, payload: Event = {}) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn("no active connection");
return;
}
this.ws.send(JSON.stringify({ type, ...payload }));
}A send that silently gives up when the socket isn't ready is fine in a debug helper and catastrophic as a pattern, because the caller has no idea its message evaporated. In a chat app the user-visible version of that is a message you watched yourself type and nobody ever received.
And then there's my reconnect, which I'm including because it's the most economical illustration of the whole problem:
reconnect() {
this.ws = new WebSocket(this.ws.url);
this.ws.send(JSON.stringify({ type: "init" }));
}That doesn't work. A freshly constructed socket is CONNECTING, not OPEN, and
calling send on it throws. The init has to wait for onopen — and once
you've accepted that, you also have to decide what happens to everything queued
while it was down, whether init is idempotent, and how the server knows this
is the same session rather than a new one.
None of which existed. Reconnection wasn't designed; it was three lines written at the moment a socket died.
why that adds up to a rewrite#
Individually both are fixable. Together they aren't, because a schema is not a layer you add later — it's the thing every handler, every stored message and every encryption boundary was already written against the absence of. Retrofitting it means touching all of that at once, which is a rewrite wearing a different word.
There was also an internal codename, a joke that was funny in a private repository and would be a liability anywhere else. The debug tools were named to match. That's its own small lesson about what "internal" means once something is meant to go public, and it's the one thing from this project I fixed for free by not shipping it.
So: a rewrite in 2026. The idea holds up and I still want to use the thing.
Schema first this time, encryption model settled against that schema rather than
around whatever already existed, and a real protocol over the socket instead of
send and optimism.
There's a longer note on the schema argument — specifically why, once messages are encrypted, an undeclared shape stops being untidiness and becomes a security boundary nobody can audit.