n4nAI

Streaming SSE responses from Echo to the browser

Learn how to implement echo golang sse streaming browser connections step by step, proxying LLM API streams to clients with proper flush and cleanup.

n4n Team3 min read665 words

Audio narration

Coming soon — every post will get a voice note here.

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 MaxIdleConnsPerHost to reuse connections.
  • Logging: Wrap res.Writer with 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.

Tagsgolangechossestreaming

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All gin & echo llm api integration posts →