Wiring up echo golang sse streaming browser connections is less about magic and more about respecting HTTP semantics. If you treat the response writer as a stream and flush aggressively, Echo can pipe tokens from an upstream LLM API to a browser with minimal latency. This guide builds a complete proxy that forwards Server-Sent Events from an OpenAI-compatible endpoint to a browser client.
Step 1: Scaffold an Echo server with SSE headers
Echo does not have a dedicated SSE handler, but its echo.Context exposes the underlying http.ResponseWriter. Set the correct headers before writing the status code, then keep the connection open.
package main
import (
"net/http"
"github.com/labstack/echo/v4"
)
func main() {
e := echo.New()
e.GET("/stream", func(c echo.Context) error {
res := c.Response()
res.Header().Set("Content-Type", "text/event-stream")
res.Header().Set("Cache-Control", "no-cache")
res.Header().Set("Connection", "keep-alive")
res.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
res.WriteHeader(http.StatusOK)
return nil
})
e.Start(":8080")
}
The X-Accel-Buffering: no header is critical if you sit behind nginx; otherwise the proxy will buffer your stream and defeat the purpose. Run this skeleton and curl -N http://localhost:8080/stream should return an empty 200 response that stays open.
Step 2: Call an upstream streaming LLM endpoint
The browser cannot talk directly to most LLM APIs due to CORS and secret keys, so Echo acts as a server-side proxy. When proxying from an OpenAI-compatible gateway such as n4n.ai, you get a single endpoint covering 240+ models with automatic fallback on provider degradation, which simplifies the upstream call. The request body uses the standard chat completion shape with stream: true.
func upstreamStream(c echo.Context) (*http.Response, error) {
reqBody := map[string]interface{}{
"model": "gpt-4o-mini",
"messages": []map[string]string{
{"role": "user", "content": c.QueryParam("prompt")},
},
"stream": true,
}
buf, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(
c.Request().Context(),
http.MethodPost,
"https://api.n4n.ai/v1/chat/completions", // OpenAI-compatible URL
bytes.NewReader(buf),
)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_KEY"))
// forward provider cache-control hint if client sent one
if cc := c.Request().Header.Get("X-Cache-Control"); cc != "" {
req.Header.Set("X-Cache-Control", cc)
}
return http.DefaultClient.Do(req)
}
Note the NewRequestWithContext call: it binds the request to the Echo context so that client disconnects cancel the upstream call automatically.
Step 3: Bridge upstream chunks to the browser
The upstream responds with newline-delimited data: {...} frames. We scan those lines, strip the prefix, and re-emit them to the browser. Flushing after every write is what makes echo golang sse streaming browser setups feel real-time.
e.GET("/stream", func(c echo.Context) error {
res := c.Response()
res.Header().Set("Content-Type", "text/event-stream")
res.Header().Set("Cache-Control", "no-cache")
res.WriteHeader(http.StatusOK)
up, err := upstreamStream(c)
if err != nil {
fmt.Fprintf(res.Writer, "event: error\ndata: %s\n\n", err.Error())
return nil
}
defer up.Body.Close()
flusher, ok := res.Writer.(http.Flusher)
if !ok {
return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
}
reader := bufio.NewReader(up.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
break // EOF or network error
}
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(line[len("data:"):])
if payload == "[DONE]" {
fmt.Fprint(res.Writer, "data: [DONE]\n\n")
flusher.Flush()
break
}
// forward verbatim; browser parses JSON
fmt.Fprintf(res.Writer, "data: %s\n\n", payload)
flusher.Flush()
}
return nil
})
Using bufio.NewReader instead of bufio.Scanner avoids the default 64KB token limit—some LLM frames can be large with embedded citations. The flusher.Flush() call pushes bytes to the TCP stack immediately.
Step 4: Write a browser client that consumes the stream
EventSource is the native browser API for SSE. It only supports GET, so we pass the prompt as a query parameter. For production, terminate TLS and add auth headers via cookies rather than query strings.
const prompt = encodeURIComponent("Explain SSE in one sentence");
const es = new EventSource(`/stream?prompt=${prompt}`);
es.onmessage = (ev) => {
if (ev.data === "[DONE]") {
es.close();
return;
}
const json = JSON.parse(ev.data);
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
document.getElementById("out").textContent += delta;
}
};
es.onerror = (err) => {
console.error("stream failed", err);
es.close();
};
Drop this into an HTML file served by Echo from a static route, or just open it via a local file and point the EventSource at http://localhost:8080/stream. The echo golang sse streaming browser pattern works without any frontend framework.
Step 5: Handle disconnects and backpressure
A user closing the tab must not leak an upstream connection. Echo’s c.Request().Context() is canceled on client disconnect; we already threaded it into the upstream request. For the write loop, add an explicit select to bail early:
for {
select {
case <-c.Request().Context().Done():
return c.Request().Context().Err()
default:
}
line, err := reader.ReadString('\n')
if err != nil { break }
// ... write and flush
}
Backpressure is minimal because SSE is unidirectional and browsers consume faster than networks deliver. If you ever batch writes, cap the per-flush size to avoid allocator churn. Also set a server-side timeout: Echo’s e.Server.WriteTimeout should be 0 (disabled) for streaming routes, or use a long-lived context with context.WithTimeout inside the handler if you need a hard cap.
Step 6: Verify the full pipeline
Start the server and hit the endpoint with curl first—it strips away browser variables.
curl -N "http://localhost:8080/stream?prompt=hello"
You should see raw data: {...} lines appearing token by token, ending with data: [DONE]. Next, load the HTML client in Chrome, open DevTools → Network → EventStream, and confirm frames arrive with text/event-stream content type. If you see the response buffered until completion, check for missing Flush() calls or proxy buffering headers.
Step 7: Production hardening
- CORS: If the browser client is served from a different origin, add
c.Response().Header().Set("Access-Control-Allow-Origin", "https://app.example.com"). - Auth: Validate a session cookie in the Echo middleware before calling
upstreamStream. Never expose the LLM API key to the client. - Concurrency: Echo handles each connection in a goroutine; ensure your upstream HTTP client has a generous
MaxIdleConnsPerHostto reuse connections. - Logging: Wrap
res.Writerwith a counter to meter per-token usage if you bill customers; the gateway already returns usage in the final frame.
The echo golang sse streaming browser architecture is fundamentally a copy loop with correct headers and flushes. Get those three things right and you can proxy any token stream—LLM, log tail, or sensor feed—with a few dozen lines of Go.