{"slug": "server-sent-events-the-web-api-i-somehow-missed-for-15-years", "title": "Server-Sent Events — The web API I somehow missed for 15 years", "summary": "A developer building a private streaming radio chat discovered Server-Sent Events (SSE), a web API standardized in 2015, after 15 years of web development. The author implemented a simple text-based chat using SSE with Go on the server and vanilla JavaScript on the client, finding it easier than WebSockets for this use case. The technology, supported by most browsers, provides automatic reconnection and uses standard HTTP semantics.", "body_md": "Doing radio is the thing I miss the most from my \ndjing days\n. In an effort to recapture some of that fun I recently built a small streaming site where me and a few friends occasionally dj for each other, sending shout outs, taking requests and spinning tunes. It runs on a small Hetzner box with an \nIcecast server\n. The stream is audio only, no ads, no cookie banners, simple but fun.\n1\n\nI wanted a chat for the listeners and dj to connect, but getting people to agree on a chat platform is like herding cats.\n2\n With the help of an AI agent I thought maybe I could just write a simple chat server myself, text-only ephemeral messages that disappear when a streaming session ends.\n\nI set out wanting to use websockets, which I had worked with a lot at \nKomp\n, but the LLM told me about a simpler alternative: \nServer-sent events\n:\n\nWith server-sent events, it’s possible for a server to send new data to a web page at any time, by pushing messages to the web page. These incoming messages can be treated as Events + data inside the web page.\n\nServer-sent events | MDN\n\nThe \nspec\n hit recommendation in 2015 and has been \nwidely supported\n for years on most browsers. Having worked on web stuff for a decade and a half I was surprised I had never heard of this technology. But I found the same was true for a few friends I asked. It feels a bit niche but also very useful; I’m not sure why we had never come across it.\n\nImplementing a simple chat using SSE was almost trivial:\n\nOne http endpoint to \nPOST\n new messages to.\n\nA server sent events endpoint that lets you \nGET\n the most recent messages and pushes new ones to connected clients.\n\nA webpage that uses the \nEventSource\n api to connect to the stream and lets users send new messages to the post endpoint. This also gets you reconnect handling for free (and the protocol supports resume via \nid:\u001b/\nLast-Event-ID` if you want it).\n\nAs for how it compares to websockets (ws): we need two endpoints/connections whereas ws probably could have done with a single bidirectional one. But we can skip the handshakes and upgrades, there’s no ping/pong to implement, and we can use just the stdlib and the HTTP semantics that we know and love. For a basic, text-based chat this is fine. For binary data or more complex chat functionality maybe you’d want to reach for ws.\n\nBelow is a barebones, minimal implementation in Go to give an idea of how simple it is. My actual chat has some input sanitization, a keepalive message, throttling, rate-limiting, etc. Nick handling is naive, no auth, but for a small friend group this has been fine (and fun). Using Go to build the server part we get a nice binary that can be deployed to the same box using Ansible. On the client side there is an html page and vanilla js that subscribes to new messages, shows them in the dom and lets you post new ones. The html gets deployed the same way.\n\n        Click to expand code sample\n    \n\n```\n    package main\n    \n    import (\n    \t\"encoding/json\"\n    \t\"fmt\"\n    \t\"log\"\n    \t\"net/http\"\n    \t\"sync\"\n    \t\"time\"\n    )\n    \n    // Message is what flows over the SSE stream.\n    type Message struct {\n    \tNick string    `json:\"nick\"`\n    \tText string    `json:\"text\"`\n    \tTs   time.Time `json:\"ts\"`\n    }\n    \n    // Hub holds connected subscribers and a ring buffer of recent messages.\n    type Hub struct {\n    \tmu   sync.Mutex\n    \tring []Message\n    \tsubs map[chan Message]struct{}\n    }\n    \n    func NewHub() *Hub {\n    \treturn &Hub{\n    \t\tring: make([]Message, 0, 20),\n    \t\tsubs: make(map[chan Message]struct{}),\n    \t}\n    }\n    \n    // Publish adds the message to the ring buffer and fans it out to all subscribers.\n    func (h *Hub) Publish(nick, text string) {\n    \th.mu.Lock()\n    \tdefer h.mu.Unlock()\n        \n    \tm := Message{Nick: nick, Text: text, Ts: time.Now().UTC()}\n        \n    \th.ring = append(h.ring, m)\n    \tif len(h.ring) > 20 {\n    \t\th.ring = h.ring[1:]\n    \t}\n    \n    \tfor ch := range h.subs {\n    \t\tselect {\n    \t\tcase ch <- m:\n    \t\tdefault:\n    \t\t\t// Subscriber too slow — drop the message rather than blocking.\n    \t\t}\n    \t}\n    }\n    \n    // Subscribe returns a channel of future messages, an unsubscribe function,\n    // and a snapshot of the ring buffer so new clients get recent history.\n    func (h *Hub) Subscribe() (<-chan Message, func(), []Message) {\n    \th.mu.Lock()\n    \tdefer h.mu.Unlock()\n        \n    \tch := make(chan Message, 32)\n    \th.subs[ch] = struct{}{}\n        \n    \tbacklog := make([]Message, len(h.ring))\n    \tcopy(backlog, h.ring)\n        \n    \tunsub := func() {\n    \t\th.mu.Lock()\n    \t\tdefer h.mu.Unlock()\n    \t\tdelete(h.subs, ch)\n    \t\tclose(ch)\n    \t}\n    \treturn ch, unsub, backlog\n    }\n    \n    func streamHandler(hub *Hub) http.HandlerFunc {\n    \treturn func(w http.ResponseWriter, r *http.Request) {\n    \t\tflusher, ok := w.(http.Flusher)\n    \t\tif !ok {\n    \t\t\thttp.Error(w, \"streaming not supported\", http.StatusInternalServerError)\n    \t\t\treturn\n    \t\t}\n        \n    \t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n    \t\tw.Header().Set(\"Cache-Control\", \"no-cache\")\n    \t\tw.Header().Set(\"Connection\", \"keep-alive\")\n    \t\tflusher.Flush()\n        \n    \t\tch, unsub, backlog := hub.Subscribe()\n    \t\tdefer unsub()\n        \n    \t\twriteEvent := func(m Message) error {\n    \t\t\tdata, err := json.Marshal(m)\n    \t\t\tif err != nil {\n    \t\t\t\treturn err\n    \t\t\t}\n    \t\t\t_, err = fmt.Fprintf(w, \"data: %s\\n\\n\", data)\n    \t\t\tflusher.Flush()\n    \t\t\treturn err\n    \t\t}\n        \n    \t\tfor _, m := range backlog {\n    \t\t\tif err := writeEvent(m); err != nil {\n    \t\t\t\treturn\n    \t\t\t}\n    \t\t}\n        \n    \t\tfor {\n    \t\t\tselect {\n    \t\t\tcase m, ok := <-ch:\n    \t\t\t\tif !ok {\n    \t\t\t\t\treturn\n    \t\t\t\t}\n    \t\t\t\tif err := writeEvent(m); err != nil {\n    \t\t\t\t\treturn\n    \t\t\t\t}\n    \t\t\tcase <-r.Context().Done():\n    \t\t\t\treturn\n    \t\t\t}\n    \t\t}\n    \t}\n    }\n    \n    func sendHandler(hub *Hub) http.HandlerFunc {\n    \ttype req struct {\n    \t\tNick string `json:\"nick\"`\n    \t\tText string `json:\"text\"`\n    \t}\n    \treturn func(w http.ResponseWriter, r *http.Request) {\n    \t\tvar in req\n    \t\tif err := json.NewDecoder(r.Body).Decode(&in); err != nil {\n    \t\t\thttp.Error(w, \"bad request\", http.StatusBadRequest)\n    \t\t\treturn\n    \t\t}\n    \t\thub.Publish(in.Nick, in.Text)\n    \t\tw.WriteHeader(http.StatusNoContent)\n    \t}\n    }\n    \n    func main() {\n    \thub := NewHub()\n        \n    \tmux := http.NewServeMux()\n    \tmux.HandleFunc(\"GET /stream\", streamHandler(hub))\n    \tmux.HandleFunc(\"POST /send\", sendHandler(hub))\n        \n    \tlog.Println(\"listening on :8080\")\n    \tlog.Fatal(http.ListenAndServe(\":8080\", mux))\n    }\n```\n\nWe’ve used the chat for a few streams in the last couple of weeks and it works like a charm. Server sent events is a tool I’ll keep in mind for use cases like this.\n\nBuilding this small “platform” using open technologies and tools was fun. We could have done all of this on a streaming provider, but it would have felt more cookie cutter and identikit as well as being prone to enshittification. This feels like our own, homegrown solution, it gives me old school indieweb vibes, think IRC and the messageboards of yore. I sincerely hope that AI agents unlock more independent projects like this, bespoke software and websites with their own aesthetics and small communi ty spirit.\n\nI don’t think it’s impossible that sort of scene could be bubbling under the surface while we are all busy complaining about the more visible aspects of AI slop waves landing on the large social media platforms.\n\nThere is some tension here, of course, will the centralized walled gardens of AI agents really unlock a new golden age of indie websites? Open weights feel more in line with that philosophy, but that’s a rabbit hole I’m holding off on for now. \n↩︎\n\nI really hate the fragmentation in chat and messaging platforms. These days every app expands to include chat functionality, so it becomes hard to keep track of different people and groups in all the different apps. \n↩︎", "url": "https://wpnews.pro/news/server-sent-events-the-web-api-i-somehow-missed-for-15-years", "canonical_source": "https://dev.karltryggvason.com/server-sent-events-the-web-api-i-somehow-missed-for-15-years/", "published_at": "2026-07-22 11:32:49+00:00", "updated_at": "2026-07-24 09:29:34.030674+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Hetzner", "Icecast", "Komp", "MDN", "Go"], "alternates": {"html": "https://wpnews.pro/news/server-sent-events-the-web-api-i-somehow-missed-for-15-years", "markdown": "https://wpnews.pro/news/server-sent-events-the-web-api-i-somehow-missed-for-15-years.md", "text": "https://wpnews.pro/news/server-sent-events-the-web-api-i-somehow-missed-for-15-years.txt", "jsonld": "https://wpnews.pro/news/server-sent-events-the-web-api-i-somehow-missed-for-15-years.jsonld"}}