# Server-Sent Events — The web API I somehow missed for 15 years

> Source: <https://dev.karltryggvason.com/server-sent-events-the-web-api-i-somehow-missed-for-15-years/>
> Published: 2026-07-22 11:32:49+00:00

Doing radio is the thing I miss the most from my 
djing days
. 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 
Icecast server
. The stream is audio only, no ads, no cookie banners, simple but fun.
1

I wanted a chat for the listeners and dj to connect, but getting people to agree on a chat platform is like herding cats.
2
 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.

I set out wanting to use websockets, which I had worked with a lot at 
Komp
, but the LLM told me about a simpler alternative: 
Server-sent events
:

With 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.

Server-sent events | MDN

The 
spec
 hit recommendation in 2015 and has been 
widely supported
 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.

Implementing a simple chat using SSE was almost trivial:

One http endpoint to 
POST
 new messages to.

A server sent events endpoint that lets you 
GET
 the most recent messages and pushes new ones to connected clients.

A webpage that uses the 
EventSource
 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 
id:/
Last-Event-ID` if you want it).

As 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.

Below 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.

        Click to expand code sample
    

```
    package main
    
    import (
    	"encoding/json"
    	"fmt"
    	"log"
    	"net/http"
    	"sync"
    	"time"
    )
    
    // Message is what flows over the SSE stream.
    type Message struct {
    	Nick string    `json:"nick"`
    	Text string    `json:"text"`
    	Ts   time.Time `json:"ts"`
    }
    
    // Hub holds connected subscribers and a ring buffer of recent messages.
    type Hub struct {
    	mu   sync.Mutex
    	ring []Message
    	subs map[chan Message]struct{}
    }
    
    func NewHub() *Hub {
    	return &Hub{
    		ring: make([]Message, 0, 20),
    		subs: make(map[chan Message]struct{}),
    	}
    }
    
    // Publish adds the message to the ring buffer and fans it out to all subscribers.
    func (h *Hub) Publish(nick, text string) {
    	h.mu.Lock()
    	defer h.mu.Unlock()
        
    	m := Message{Nick: nick, Text: text, Ts: time.Now().UTC()}
        
    	h.ring = append(h.ring, m)
    	if len(h.ring) > 20 {
    		h.ring = h.ring[1:]
    	}
    
    	for ch := range h.subs {
    		select {
    		case ch <- m:
    		default:
    			// Subscriber too slow — drop the message rather than blocking.
    		}
    	}
    }
    
    // Subscribe returns a channel of future messages, an unsubscribe function,
    // and a snapshot of the ring buffer so new clients get recent history.
    func (h *Hub) Subscribe() (<-chan Message, func(), []Message) {
    	h.mu.Lock()
    	defer h.mu.Unlock()
        
    	ch := make(chan Message, 32)
    	h.subs[ch] = struct{}{}
        
    	backlog := make([]Message, len(h.ring))
    	copy(backlog, h.ring)
        
    	unsub := func() {
    		h.mu.Lock()
    		defer h.mu.Unlock()
    		delete(h.subs, ch)
    		close(ch)
    	}
    	return ch, unsub, backlog
    }
    
    func streamHandler(hub *Hub) http.HandlerFunc {
    	return func(w http.ResponseWriter, r *http.Request) {
    		flusher, ok := w.(http.Flusher)
    		if !ok {
    			http.Error(w, "streaming not supported", http.StatusInternalServerError)
    			return
    		}
        
    		w.Header().Set("Content-Type", "text/event-stream")
    		w.Header().Set("Cache-Control", "no-cache")
    		w.Header().Set("Connection", "keep-alive")
    		flusher.Flush()
        
    		ch, unsub, backlog := hub.Subscribe()
    		defer unsub()
        
    		writeEvent := func(m Message) error {
    			data, err := json.Marshal(m)
    			if err != nil {
    				return err
    			}
    			_, err = fmt.Fprintf(w, "data: %s\n\n", data)
    			flusher.Flush()
    			return err
    		}
        
    		for _, m := range backlog {
    			if err := writeEvent(m); err != nil {
    				return
    			}
    		}
        
    		for {
    			select {
    			case m, ok := <-ch:
    				if !ok {
    					return
    				}
    				if err := writeEvent(m); err != nil {
    					return
    				}
    			case <-r.Context().Done():
    				return
    			}
    		}
    	}
    }
    
    func sendHandler(hub *Hub) http.HandlerFunc {
    	type req struct {
    		Nick string `json:"nick"`
    		Text string `json:"text"`
    	}
    	return func(w http.ResponseWriter, r *http.Request) {
    		var in req
    		if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
    			http.Error(w, "bad request", http.StatusBadRequest)
    			return
    		}
    		hub.Publish(in.Nick, in.Text)
    		w.WriteHeader(http.StatusNoContent)
    	}
    }
    
    func main() {
    	hub := NewHub()
        
    	mux := http.NewServeMux()
    	mux.HandleFunc("GET /stream", streamHandler(hub))
    	mux.HandleFunc("POST /send", sendHandler(hub))
        
    	log.Println("listening on :8080")
    	log.Fatal(http.ListenAndServe(":8080", mux))
    }
```

We’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.

Building 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.

I 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.

There 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. 
↩︎

I 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. 
↩︎
