cd /news/developer-tools/i-poked-a-10-year-old-chat-protocol-… · home topics developer-tools article
[ARTICLE · art-56438] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

I Poked a 10-Year-Old Chat Protocol With a Stick

Developer Maneshwar spent a weekend exploring lhttp, a decade-old chat protocol built on websockets that mimics HTTP syntax. Despite being written for Go 1.6 and using outdated dependency management, the project compiled and ran on Go 1.24 without any modifications. The protocol uses NATS for server-to-server communication and features a simple text-based wire format.

read7 min views1 publishedJul 12, 2026

Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback.

So here's how I spent my weekend.

I went digging through old GitHub repos looking for something forgotten, found a project called lhttp, and instead of just reading the README like a reasonable person, I cloned it, compiled it, ran it, and poked it with a stick until it did something weird.

It did something weird. We'll get there.

lhttp describes itself as "a http like protocol using websocket to provide long live, build your IM service quickly scalable without XMPP."

Translation: it's a tiny text protocol, styled like HTTP, that rides on top of a websocket connection instead of a raw TCP socket.

The pitch is that you get HTTP's readability (commands, headers, a body) but with a connection that stays open, so you can push messages to clients instead of waiting for them to ask nicely.

Under the hood, when you want to run more than one lhttp server (because one server is never enough once your chat app takes off, right?), all the servers talk to each other through NATS, specifically an ancient bundled copy of gnatsd

.

Every server subscribes to the same message bus, so it doesn't matter which server a client is connected to.

Publish a message on server 1, and a client sitting on server 3 still gets it.

Here's the cluster idea, minus the ASCII art from the README:

Nice idea, honestly. Small gnat-sized message broker, quietly doing the heavy lifting so the actual servers can stay dumb and stateless.

Naming a broker after an insect that's small but annoyingly persistent feels like accidental self-awareness.

The wire protocol itself is charmingly blunt:

LHTTP/1.0 chat
content-type:json
publish:channel_jack

{"to":"jack","message":"hello jack"}

Command line, headers, blank line, body.

If you've ever hand-rolled an HTTP request in telnet for fun, this will feel instantly familiar.

Here's where I expected pain.

This repo uses Godeps

, a vendor/

folder frozen in time, and a Godeps.json

that proudly declares "GoVersion": "go1.6"

.

For context, Go modules didn't exist yet in 2016.

People vendored dependencies by literally copying the source into your repo and hoping nobody touched it again.

I have Go 1.24 installed. I fully expected to spend my evening untangling import paths.

export GOPATH=/some/path/gopath
mkdir -p "$GOPATH/src/github.com/fanux"
ln -s /path/to/lhttp "$GOPATH/src/github.com/fanux/lhttp"
cd "$GOPATH/src/github.com/fanux/lhttp"
GO111MODULE=off go build .

Exit code zero. First try. No fights, no missing symbols, no "this API doesn't exist anymore."

Old-school GOPATH mode plus a vendor folder from the Obama administration, and Go 1.24 just shrugged and compiled it.

Same story for the actual demo server (websocketServer/test.go

, which despite the filename is a real main

package) and the tiny static file server in lhttp-web-demo that just serves the chat UI. Both compiled clean with zero source changes.

One small archaeology note for anyone following along: the main README's Docker instructions tell you to expose port 8081 for the lhttp server.

The actual source code hardcodes :8581

.

One digit off, in the docs, for what appears to be the project's whole life. Nobody tell them. Actually, somebody should tell them.

Three processes, three jobs:

./gnatsd -p 4222 &          # the message bus
./lhttp_server &             # the actual chat server, listening on :8581
./demo_server &               # serves index.html on :9090

With everything up, I didn't even bother with a browser first.

I went straight for a raw websocket connection in Python, because I wanted to see the bytes.

ws.send("LHTTP/1.0 chat\r\n\r\nHello from the blog experiment!")
ws.recv()

I sent plain text under the chat

command.

I got back a response labeled command auth

, with a header claiming the content type is image/png

. My text message. Labeled as a PNG.

The demo's ChatProcessor

apparently relabels every reply this way regardless of what you send it, which I can only assume was a debugging leftover that nobody removed before shipping the reference demo.

It's the kind of bug you'd only find by actually running the thing instead of reading the README, which is exactly why I'm writing this instead of just linking you the repo.

Pub/sub worked exactly as advertised, though. Two connections, one subscribes to a channel, the other publishes:

ws.send("LHTTP/1.0 subpub\r\nsubscribe:room1\r\n\r\n")
ws.send("LHTTP/1.0 subpub\r\npublish:room1\r\n\r\nHey room1, anyone here?")

Clean fanout through NATS, no drama. Good, functioning building block.

The actual chat demo (the one with the Vue.js frontend and the little GIF in the README) doesn't use a generic subpub

command.

It uses chat

for everything, subscribing to a channel called chatroom

the moment the page loads, then publishing to that same channel every time you hit Send.

So I replicated exactly what the browser does, using two raw connections named, obviously, Jack and Mike.

jack.send("LHTTP/1.0 chat\r\nsubscribe:chatroom\r\n\r\n")
mike.send("LHTTP/1.0 chat\r\nsubscribe:chatroom\r\n\r\n")

jack.send("LHTTP/1.0 chat\r\npublish:chatroom\r\n\r\nyo mike, lhttp works!")

Jack received two messages back. Mike received one.

jack got:
  'LHTTP/1.0 auth\r\ncontent-type:image/png\r\n\r\nyo mike, lhttp works!'
  'LHTTP/1.0 chat\r\npublish:chatroom\r\n\r\nyo mike, lhttp works!'

mike got:
  'LHTTP/1.0 chat\r\npublish:chatroom\r\n\r\nyo mike, lhttp works!'

Here's why. The chat

command handler always fires off a direct personal reply the instant it receives anything (that's the mislabeled "auth"/"image/png" echo from earlier).

Separately, and completely independently, the pub/sub filter re-publishes your message to the NATS channel you're posting to.

Since you subscribed to your own chatroom when you opened the connection, you're also a subscriber of the very channel you just published to.

Nobody else in the room sees the duplicate. Only the sender does.

Which means if you were building on this today without checking, you'd get bug reports along the lines of "why does my own message show up twice but nobody else's does," and the answer would sound completely made up until you draw this exact diagram.

The README claims 10,000 messages published in 0.04 seconds on a single core with 1GB of RAM, which is a wildly specific benchmark from 2016 that I have no way to reproduce on identical hardware anymore.

Instead of just repeating someone else's decade-old number, I ran my own, on whatever this machine happens to be:

for i in range(10000):
    ws.send(f"LHTTP/1.0 chat\r\n\r\nmsg-{i}")
for i in range(10000):
    ws.recv()

10,000 request/response round trips over a single websocket, from a single-threaded Python client, no batching tricks: 0.558 seconds, about 17,900 messages per second.

Not the same benchmark, not the same hardware, not really a fair comparison at all, but it does confirm the core loop isn't doing anything silly like blocking on disk or sleeping between messages.

For a text parser held together with string indexing (buildMessage

in wsHandler.go

walks the raw string character by character, no regex, no fancy tokenizer), that's respectable.

Honestly? Probably not for anything new.

golang.org/x/net/websocket

(what lhttp is built on) has been the "please use gorilla/websocket or nhooyr.io/websocket instead" example in Go circles for years now, and the protocol's own reference demo has a bug you can find in about ten minutes of poking.

But as a piece of "here's how people solved real-time messaging before everyone standardized on Socket.IO or plain WebSocket JSON frames," it's a genuinely fun read, and it's kind of wonderful that a 2016 vendor folder can still be handed to a 2026 Go compiler and just work without complaint.

If you want to try breaking it yourself, the two repos are linked above. Bring your own Jack and Mike.

AI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.

git-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.

Any feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.

⭐ Star it on GitHub:

| 🇩🇰 Dansk | 🇪🇸 Español | 🇮🇷 Farsi | 🇫🇮 Suomi | 🇯🇵 日本語 | 🇳🇴 Norsk | 🇵🇹 Português | 🇷🇺 Русский | 🇦🇱 Shqip | 🇨🇳 中文 | 🇮🇳 हिन्दी |

GenAI today is a race car without brakes. It accelerates fast -- you describe something, and large blocks of code appear instantly. But AI agents silently break things: they remove logic, relax constraints, introduce expensive cloud calls, leak credentials, and change behavior -- without telling you. You often find out in production.

** git-lrc is your braking system.** It hooks into

git commit

and runs an AI review on every diff In short, git-lrc helps Prevent Outages, Breaches, and Technical Debt Before They Happen

At a glance: 10 risk categories · 100+ failure patterns tracked · every commit…

── more in #developer-tools 4 stories · sorted by recency
── more on @maneshwar 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-poked-a-10-year-ol…] indexed:0 read:7min 2026-07-12 ·