A demonstration project showcasing Server-Sent Events (SSE) and polling patterns using Go, HTMX, and Templ.
This project demonstrates real-time communication patterns in web applications:
- Server-Sent Events (SSE) - Server pushes updates to the client
- Polling - Client periodically requests updates from the server
- Comparison of both approaches with practical examples
- Backend: Go (1.22+) with standard library HTTP server
- Templating: Templ - Type-safe HTML templates
- Frontend: HTMX 2.0 - High-level hypermedia library
- Styling: Tailwind CSS - Utility-first CSS framework
- AI Integration: OpenAI Go SDK - For real-world SSE streaming examples
- Hot Reload: Air for Go, Templ watch mode, Tailwind watch
- Architecture: MVC pattern with controllers, views, and middleware
- Go 1.22 or higher
- Node.js and npm (for Tailwind CSS)
- Templ CLI (
go install github.com/a-h/templ/cmd/templ@latest
) - Air for hot reload (
go install github.com/cosmtrek/air@latest
) - OpenAI API key (optional, for Real Example features)
- Clone the repository:
git clone https://github.com/devhulk/go-htmx-sse.git
cd go-htmx-sse
- Install Go dependencies:
go mod download
- Install Node dependencies:
npm install
- Install Templ CLI if not already installed:
go install github.com/a-h/templ/cmd/templ@latest
- (Optional) Set up OpenAI API key for the Real Example:
export OPENAI_API_KEY="your-openai-api-key-here"
make live
This starts 4 concurrent processes:
templ generate --watch
- Watches and regenerates Templ filesair
- Go server with hot reloadtailwindcss --watch
- Watches and compiles CSS- Asset sync for browser reload
The server will be available at http://localhost:8080
make build
./main
/
- Home page with SSE and polling demos side by side/poll
- Advanced polling examples with different intervals/sse-alt
- Alternative SSE implementation using vanilla JavaScript/sse-debug
- Debug page for testing different SSE configurations/sse-multi
- Multiple SSE event types demonstration/real-example
- OpenAI Integration demonstrating both polling and SSE approaches with real API calls
.
├── main.go # Entry point and route definitions
├── controllers/ # HTTP handlers
│ ├── home.go # Home page with HTMX navigation support
│ ├── openai.go # OpenAI integration (polling + SSE streaming)
│ ├── poll.go # Polling examples with different intervals
│ ├── sse.go # Basic SSE endpoint
│ ├── sse_multi.go # Multi-event SSE demonstrations
│ └── status.go # Status endpoint for polling examples
├── views/ # Templ templates
│ ├── layout.templ # Base layout with HTMX navigation
│ ├── home.templ # Home page with content separation
│ ├── openai_example.templ # Real Example page (OpenAI integration)
│ ├── poll.templ # Polling examples
│ ├── sse_multi.templ # Multi-event SSE demo
│ └── sse_debug.templ # SSE debugging and testing page
├── middleware/ # HTTP middleware
│ └── logging.go # Request logging with Flusher support for SSE
├── assets/ # Static files
│ ├── css/ # Tailwind input files
│ ├── js/ # HTMX 2.0+ and SSE extension
│ └── output.css # Generated Tailwind output
├── docs.md # Detailed project documentation and patterns
├── Makefile # Build and development commands
├── .air.toml # Air hot reload configuration
└── go.mod/go.sum # Go module dependencies
Problem: The SSE extension must be compatible with your HTMX version. Using mismatched versions will cause errors like:
Uncaught TypeError: api.swap is not a function
Solution: Always download the SSE extension from the same HTMX version:
✅ Correct approach:
# Download HTMX 2.0.6
curl -L -o assets/js/htmx.min.js https://unpkg.com/[email protected]/dist/htmx.min.js
# Download SSE extension FROM THE SAME VERSION
curl -L -o assets/js/htmx-ext-sse.js https://unpkg.com/[email protected]/dist/ext/sse.js
❌ Wrong approach:
# Don't mix versions or use third-party packages
curl -L -o assets/js/htmx.min.js https://unpkg.com/[email protected]/dist/htmx.min.js
curl -L -o assets/js/htmx-ext-sse.js https://unpkg.com/[email protected]/sse.js # Different package!
Problem: SSE requires the http.Flusher
interface for streaming responses. Middleware that wraps http.ResponseWriter
must implement this interface.
Solution: Ensure your middleware properly implements Flush()
:
type responseWriter struct {
http.ResponseWriter
statusCode int
}
// Must implement Flush for SSE support
func (rw *responseWriter) Flush() {
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
Problem: HTMX SSE extension expects specific event names when using sse-swap
.
Solution: Send events with the correct format:
// Event name must match what you specify in sse-swap="message"
fmt.Fprintf(w, "event: message\n")
fmt.Fprintf(w, "data: <div>Your HTML content here</div>\n\n")
w.(http.Flusher).Flush()
Browsers limit the number of SSE connections per domain (typically 6). Keep this in mind when designing applications with multiple SSE streams.
Problem: You want to handle different types of SSE events (not just "message" events) and route them to different parts of your UI.
Solution: Use custom event names and the sse-swap
attribute:
// Server sends different event types
fmt.Fprintf(w, "event: message\n")
fmt.Fprintf(w, "data: <div>Regular message</div>\n\n")
fmt.Fprintf(w, "event: alert\n")
fmt.Fprintf(w, "data: <div>Alert notification</div>\n\n")
fmt.Fprintf(w, "event: status\n")
fmt.Fprintf(w, "data: <div>Status update</div>\n\n")
<!-- Client listens for specific events -->
<div hx-ext="sse" sse-connect="/events">
<!-- Only receives "message" events -->
<div sse-swap="message">...</div>
<!-- Only receives "alert" events -->
<div sse-swap="alert">...</div>
<!-- Receives both "message" and "alert" events -->
<div sse-swap="message,alert">...</div>
<!-- Triggers HTMX request when "alert" event arrives -->
<div hx-trigger="sse:alert" hx-get="/handle-alert">...</div>
</div>
Problem: Creating multiple SSE connections (sse-connect
) on the same page causes errors and hits browser connection limits.
Solution: Use a single parent element with sse-connect
and multiple child elements with sse-swap
:
❌ Wrong - Multiple connections:
<div hx-ext="sse" sse-connect="/events">
<div sse-swap="message">Messages here</div>
</div>
<div hx-ext="sse" sse-connect="/events"> <!-- Creates another connection! -->
<div sse-swap="alert">Alerts here</div>
</div>
✅ Correct - Single connection:
<div hx-ext="sse" sse-connect="/events"> <!-- Single connection -->
<div sse-swap="message">Messages here</div>
<div sse-swap="alert">Alerts here</div>
<div sse-swap="status">Status here</div>
</div>
Benefits:
- More efficient - uses only one connection
- Avoids browser connection limits
- All listeners share the same EventSource
- Cleaner architecture
- HTMX Navigation: All navbar links use HTMX for fast client-side navigation
- Template Generation: Run
templ generate
after template changes - SSE Debugging: Use
/sse-debug
page and browser DevTools - Connection Monitoring: Check console for connection count logs
- Multiple Events: Organize events by type (message, alert, status, etc.)
- OpenAI Testing: Use
/real-example
to test both polling and streaming approaches - SSE Cleanup: Monitor server logs to ensure connections are properly closed
- Check browser console - SSE connection issues and HTMX events are logged there
- Test SSE endpoint directly:
curl -N -H "Accept: text/event-stream" http://localhost:8080/events
- Monitor SSE connections in browser DevTools:
- Network tab → Filter by "EventStream"
- See connection status, events received, and any errors
- Use custom event types to organize your real-time updates:
message
for general updatesalert
for notificationsstatus
for state changes- Create your own domain-specific events
- Container Cleanup: Use
hx-swap="outerHTML"
for SSE cleanup patterns - Polling Status: Implement progressive status messages for better UX
- You have mismatched HTMX and SSE extension versions
- Download both from the same HTMX release
- Check browser console for errors
- Verify the
/events
endpoint is accessible - Ensure middleware implements
http.Flusher
- Check if you have multiple
sse-connect
attributes (should only have one per connection)
- Look for multiple
sse-connect
attributes on the same page - Consolidate to a single parent element with
sse-connect
- Child elements should only have
sse-swap
attributes
- Verify event names match between server and client
- Server:
fmt.Fprintf(w, "event: myevent\n")
- Client:
<div sse-swap="myevent">
- Check browser DevTools Network tab for EventStream data
- Make sure Tailwind watcher is running (
make live
includes this) - Check that
assets/output.css
is being generated
- Ensure Templ watcher is running
- Manually run
templ generate
if needed - Check for
*_templ.go
files being generated
- Verify
OPENAI_API_KEY
environment variable is set - Check API key validity and quota
- Monitor network requests in browser DevTools
- Check server logs for OpenAI API errors
- Ensure
sse-connect
element is removed from DOM after completion - Use
hx-swap="outerHTML"
to replace entire container - Avoid manual
sse-close
management - Check for duplicate SSE connector elements
- Verify polling interval is set correctly (1-2 seconds recommended)
- Check that status endpoint returns different messages based on elapsed time
- Ensure background processing is tracking start time properly
- Controllers must detect
HX-Request
header and return content-only templates - Full page templates should only be used for direct requests
- Check that navbar links have proper
hx-get
andhx-target
attributes
The OpenAI SSE implementation demonstrates a complete workflow from form submission to streaming completion. Here's the detailed step-by-step process:
- User fills form and clicks "Generate with SSE" button
- Form submits with
hx-post="/openai-sse-start"
targeting#sse-container
Returns HTML with HTMX SSE extension setup:
<div hx-ext="sse" sse-connect="/openai-sse?prompt=...">
<div sse-swap="message"></div> <!-- Initial status messages -->
<div sse-swap="update"></div> <!-- Streaming content updates -->
<div sse-swap="complete" hx-swap="outerHTML" hx-target="#sse-container"></div>
<div sse-swap="error"></div> <!-- Error handling -->
</div>
Key Elements:
hx-ext="sse"
- Loads HTMX SSE extensionsse-connect
- Establishes EventSource connection to streaming endpointsse-swap
- Defines which elements listen for specific event typeshx-swap="outerHTML"
- Critical for cleanup (replaces entire container)
- When HTML is inserted into DOM, HTMX automatically creates
EventSource
- Connects to
/openai-sse?prompt=...
- Sets up event listeners for different event types
A. Initial Setup:
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Send initial status
fmt.Fprintf(w, "event: message\n")
fmt.Fprintf(w, "data: <div>Connecting to OpenAI...</div>\n\n")
flusher.Flush()
B. OpenAI Streaming Loop:
stream, err := openaiClient.CreateChatCompletionStream(ctx, req)
var fullResponse strings.Builder
for {
response, err := stream.Recv()
if err == io.EOF { break }
content := response.Choices[0].Delta.Content
if content != "" {
fullResponse.WriteString(content)
// Send accumulated response so far
fmt.Fprintf(w, "event: update\n")
fmt.Fprintf(w, "data: <p>%s</p>\n\n", fullResponse.String())
flusher.Flush()
}
if response.Choices[0].FinishReason != "" { break }
}
C. Completion and Cleanup:
// Send final response with clean container structure
fmt.Fprintf(w, "event: complete\n")
fmt.Fprintf(w, "data: <div id=\"sse-container\" class=\"mt-4 min-h-[100px]\">")
fmt.Fprintf(w, "<div>%s</div>", finalResponse)
fmt.Fprintf(w, "</div>\n\n")
flusher.Flush()
// Function return closes HTTP connection
Throughout the stream:
- "message" events → Update initial status div
- "update" events → Replace content showing progressive response
- "complete" event →
outerHTML
swap replaces entire#sse-container
- "error" events → Display error messages
When "complete" event fires:
hx-swap="outerHTML"
replaces the entire SSE connector div- Removing
sse-connect
element from DOM triggers HTMX cleanup - EventSource connection automatically closes
- New container is ready for next request
- Proper Event Structure: Each SSE event must have
event:
anddata:
lines - Immediate Flushing: Call
flusher.Flush()
after each event - Container Replacement: Use
outerHTML
to replace SSE connector for cleanup - Complete Response Inclusion: Final event includes full response text
- Natural Connection Closure: HTTP function return closes EventSource
- Connection Reuse: Each request gets fresh connection (no session conflicts)
- Manual Cleanup: No complex JavaScript or
sse-close
management needed - Response Preservation: Complete response included in final event
- Event Type Confusion: Clear separation between update and complete events
MIT