Building a Live Collaborative Markdown Editor with Go, HTMX, and WebSockets
Master real-time HTML streaming. Learn how to bind WebSockets to HTMX on a Go backend to sync document edits instantly across users.

Master real-time HTML streaming. Learn how to bind WebSockets to HTMX on a Go backend to sync document edits instantly across users.
Building a Live Collaborative Markdown Editor with Go, HTMX, and WebSockets
Collaborative, real-time document editing (like Google Docs or Notion) is typically considered the exclusive territory of heavy client-side JavaScript applications. Developers automatically reach for React, complex CRDT libraries, and bulky state-synchronization engines.
But what if you could build a fully collaborative, multi-user document editor with zero custom client-side JavaScript?
By combining the concurrent power of Go (using goroutines and channels), standard WebSockets, and HTMX's native WebSocket extension, you can broadcast real-time markdown updates and compiled previews to dozens of connected clients concurrently in under 5 milliseconds.
In this guide, we will implement this complete real-time collaborative system from scratch.
⚡ 1. The Real-Time HTMX WebSocket Flow
HTMX provides a dedicated WebSockets extension (ext/ws) that lets you bind a WebSocket connection directly to a DOM element.
- Client Outbound: When a user types in a textarea, HTMX captures the input and pushes a standard serialized form value as a WebSocket packet automatically.
- Server Broadcast: The Go backend receives the update, parses the raw markdown, compiles it to secure HTML, and broadcasts the updated HTML fragment down the WebSocket to all other connected users.
- Client Inbound: When an HTML fragment arrives via the WebSocket, HTMX intercepts it and swaps it directly into the targeted preview container automatically!
[User Types Markdown] ──(HTMX Auto-Post over WS)──> [Go Websocket Hub]
│
[HTML Preview Swapped Into DOM] <──(Broadcast HTML) <────────┘
🏗️ 2. The Interactive HTML Layout
First, let's write our semantic HTML workspace, declaring our WebSocket connection and targeting the preview container using HTMX.
html<!-- index.html --> <!DOCTYPE html> <html> <head> <title>Go + HTMX Live Collab Editor</title> <!-- Load HTMX and the WebSocket Extension --> <script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/ws.js"></script> </head> <body> <!-- 1. Open the WebSocket connection globally over the parent div --> <div hx-ext="ws" ws-connect="/ws/editor" class="editor-container"> <!-- 2. The Text Editor (Automatically pushes values to the socket on key changes) --> <div class="editor-pane"> <h3>Markdown Editor</h3> <form ws-send id="editor-form"> <textarea name="markdown" placeholder="Start typing markdown collaboratively..." hx-trigger="keyup changed delay:100ms" ></textarea> </form> </div> <!-- 3. The Preview Pane (HTMX swaps incoming server-broadcast fragments here) --> <div class="preview-pane"> <h3>Live HTML Preview</h3> <div id="markdown-preview"> <p>Waiting for edits...</p> </div> </div> </div> </body> </html>
💻 3. Coding the Go Concurrent WebSocket Hub
Now, let's implement the Go backend using the standard gorilla/websocket library. We'll design a thread-safe Hub that registers active clients, coordinates broad-casts, and compiles markdown on the fly using a lightweight Go Markdown library like yuin/goldmark.
gopackage main import ( "bytes" "fmt" "net/http" "sync" "github.com/gorilla/websocket" "github.com/yuin/goldmark" ) var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, } // Hub manages active connections and broadcasts type Hub struct { clients map[*websocket.Conn]bool broadcast chan []byte mutex sync.Mutex } var hub = Hub{ clients: make(map[*websocket.Conn]bool), broadcast: make(chan []byte), } func (h *Hub) register(conn *websocket.Conn) { h.mutex.Lock() defer h.mutex.Unlock() h.clients[conn] = true } func (h *Hub) unregister(conn *websocket.Conn) { h.mutex.Lock() defer h.mutex.Unlock() delete(h.clients, conn) conn.Close() } func (h *Hub) runBroadcastLoop() { for { message := <-h.broadcast h.mutex.Lock() for client := range h.clients { // Write HTML fragment down the socket in a separate thread to prevent blocks go func(c *websocket.Conn, msg []byte) { c.WriteMessage(websocket.TextMessage, msg) }(client, message) } h.mutex.Unlock() } }
🚀 4. Compiling & Streaming HTML Fragments
Now, let's write our WebSocket message handler. When a user types, the server parses the incoming form value, compiles the markdown, and formats it as a targeted HTMX preview fragment before triggering a global broadcast:
gofunc wsHandler(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } hub.register(conn) defer hub.unregister(conn) for { _, message, err := conn.ReadMessage() if err != nil { break } // Message arrives from HTMX as standard form-urlencoded parameters: // e.g. "markdown=##+Hello+World" parsedMarkdown := parseFormValue(message, "markdown") // 1. Compile raw markdown to secure HTML using Goldmark var buf bytes.Buffer if err := goldmark.Convert([]byte(parsedMarkdown), &buf); err != nil { continue } // 2. Wrap compiled HTML inside a targeted HTMX swap fragment // The ID must match the preview container's ID in the DOM htmxFragment := fmt.Sprintf( `<div id="markdown-preview" hx-swap-oob="true">%s</div>`, buf.String(), ) // 3. Broadcast updated preview to all active users! hub.broadcast <- []byte(htmxFragment) } } func main() { go hub.runBroadcastLoop() http.HandleFunc("/ws/editor", wsHandler) http.ListenAndServe(":8080", nil) }
🏁 5. Conclusion
By delegating concurrent broadcasts to Go goroutines and utilizing HTMX's WebSockets extension to manage target swaps automatically, you eliminate the need for heavy client-side frameworks completely. Collaborative updates sync instantly in under 5ms, delivering a secure, blazing-fast real-time editor that runs beautifully on standard browsers.

SQLite on the Edge: Replicating Databases with LiteFS and Fly.io
A technical dive into distributed edge storage, exploring how LiteFS replicates SQLite databases across global Fly.io regions using FUSE and lease-based consensus.

Implementing Post-Quantum Cryptography in Next.js: Securing APIs against Future Decryption
Future-proof your web applications today. Learn how to secure Next.js API routes using Post-Quantum Cryptography (PQC) algorithms like ML-KEM and Kyber.