Updated Nov 5, 20266 min read

How to Implement Server-Sent Events (SSE) in Go

Stream events from a Go HTTP handler with the standard library, handle disconnects, heartbeats and reconnects, and run the same handler next to a Postgres database locally and on AWS or GCP.

Server-Sent Events let a Go service push a stream of updates to a browser over a single HTTP response, and the standard library already has everything needed to serve them. The parts that trip people up are not the format but the edges: buffered writes that never reach the client, a WriteTimeout that kills the stream after 30 seconds, a load balancer that closes idle connections, and goroutines that keep running after the user closed the tab.

This guide builds an SSE handler with net/http step by step, then shows the same handler running as an Encore (a tool that works alongside your framework) endpoint that streams job progress from Postgres, locally with encore run and in your own AWS or GCP account.

How SSE works

The client sends a normal GET request, and the server answers with Content-Type: text/event-stream and keeps the response open, writing messages as lines of data:, with optional event: and id: lines, each message ending with a blank line. In the browser, EventSource parses the stream, dispatches one event per message and reconnects automatically if the connection drops, sending the last id it saw in a Last-Event-ID header. The format is defined in the HTML standard, and lines starting with a colon are comments, which is how servers send heartbeats.

A minimal SSE handler with net/http

The handler sets the headers, writes a message, and flushes it so it leaves the server's buffer. http.ResponseController, added in Go 1.20, gives access to Flush and per-request deadlines without type-asserting http.Flusher:

package main import ( "fmt" "log" "net/http" "time" ) func events(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") rc := http.NewResponseController(w) // A long-lived stream must not inherit the server's WriteTimeout. _ = rc.SetWriteDeadline(time.Time{}) tick := time.NewTicker(2 * time.Second) defer tick.Stop() heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() id := 0 for { select { case <-r.Context().Done(): // The client closed the connection. return case t := <-tick.C: id++ fmt.Fprintf(w, "id: %d\nevent: tick\ndata: {\"at\":%q}\n\n", id, t.Format(time.RFC3339)) case <-heartbeat.C: fmt.Fprint(w, ": ping\n\n") } if err := rc.Flush(); err != nil { return } } } func main() { srv := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(events), ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, } log.Fatal(srv.ListenAndServe()) }

Run it with go run . and open the stream with curl -N localhost:8080, where -N disables curl's own buffering so each message prints as it arrives.

Handle disconnects, timeouts and reconnects

The request context is cancelled when the client disconnects, so every SSE loop should select on r.Context().Done() and return, otherwise each closed tab leaves a goroutine and a ticker behind. The WriteTimeout on http.Server covers the entire response, which is right for normal endpoints and wrong for a stream, and SetWriteDeadline(time.Time{}) clears it for this handler only.

Proxies and load balancers close connections that look idle; the AWS Application Load Balancer, for example, has a default idle timeout of 60 seconds. The heartbeat comment every 15 seconds keeps the connection active without showing up as an event in the browser. For reconnects, give each message an id and read r.Header.Get("Last-Event-ID") at the start of the handler, so a reconnecting client resumes from the next message instead of starting over.

Stream from a source shared by every instance

An in-memory channel works while the service is a single process, but once it runs on several instances behind a load balancer a client can be connected to any of them, so the stream should read from a source every instance can see, such as a database row the worker updates. The Encore example below does that by polling a job's progress from Postgres and closing the stream when the job finishes.

Set up the Encore app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

Create an app from the Go hello-world template and add a jobs service:

~
$ encore app create progress --example=hello-world$ cd progress && mkdir -p jobs/migrations

SSE as a raw endpoint next to a database with Encore

Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. A raw endpoint is a regular Go HTTP handler, so the SSE code above carries over unchanged, and the Postgres database it reads from is declared in the same service, which encore run starts locally with the migrations applied.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

The migration creates the table the worker updates:

-- jobs/migrations/1_create_jobs.up.sql CREATE TABLE jobs ( id BIGSERIAL PRIMARY KEY, percent INT NOT NULL DEFAULT 0, stage TEXT NOT NULL DEFAULT 'queued' );

The endpoint looks up the job every second and sends an event whenever the progress changes:

// jobs/events.go package jobs import ( "encoding/json" "errors" "fmt" "net/http" "time" "encore.dev" "encore.dev/beta/errs" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("jobs", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type progress struct { Percent int `json:"percent"` Stage string `json:"stage"` } //encore:api public raw method=GET path=/jobs/:id/events func Events(w http.ResponseWriter, req *http.Request) { id := encore.CurrentRequest().PathParams.Get("id") ctx := req.Context() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") rc := http.NewResponseController(w) _ = rc.SetWriteDeadline(time.Time{}) var last progress tick := time.NewTicker(time.Second) defer tick.Stop() for { var p progress err := db.QueryRow(ctx, `SELECT percent, stage FROM jobs WHERE id = $1`, id).Scan(&p.Percent, &p.Stage) if errors.Is(err, sqldb.ErrNoRows) { http.Error(w, "job not found", http.StatusNotFound) return } else if err != nil { errs.HTTPError(w, err) return } if p != last { data, _ := json.Marshal(p) fmt.Fprintf(w, "event: progress\ndata: %s\n\n", data) last = p } else { fmt.Fprint(w, ": ping\n\n") } if rc.Flush() != nil { return } if p.Percent >= 100 { return } select { case <-ctx.Done(): return case <-tick.C: } } }

Run it with encore run, insert a row with encore db shell jobs, and open curl -N localhost:4000/jobs/1/events while you update the row to watch events arrive. The local development dashboard shows a trace for the request with each database query. Raw endpoints support :id path parameters like typed endpoints, read with encore.CurrentRequest().PathParams, and the receiving HTTP requests guide covers WebSockets on the same endpoint type if the client later needs to send messages too.

On deploy, Encore provisions the database as RDS on AWS or Cloud SQL on GCP, runs the service on Fargate, EKS, Cloud Run or GKE behind the load balancer it provisions, and provisions an IAM role for the service. The worker that updates the job can live in another service and receive work through a Pub/Sub topic declared in the same codebase. A coding agent adding a stream to this app writes one handler and checks it against the real local database in the same session.

Best fit: Go teams who want streaming endpoints that read from the same Postgres and Pub/Sub as the rest of the service, deployed to their own AWS or GCP account.

Frequently asked questions

How do I implement Server-Sent Events in Go?

Write a normal http.HandlerFunc that sets Content-Type to text/event-stream, writes messages in the form data: ...\n\n, and calls Flush after each one through http.ResponseController. Return when r.Context().Done() fires, which happens when the client disconnects.

Why does my Go SSE stream close after 30 seconds?

The http.Server WriteTimeout applies to the whole response, so a long-lived stream hits it. Clear the deadline for the SSE handler with http.NewResponseController(w).SetWriteDeadline(time.Time{}), and check proxy or load balancer idle timeouts, which a periodic heartbeat comment keeps from firing.

Should I use SSE or WebSockets in Go?

Use SSE when the server pushes updates and the client only listens, such as notifications, job progress or streamed AI output, since it works over plain HTTP and EventSource reconnects automatically. Use WebSockets when the client also sends a steady stream of messages.

How do I add an SSE endpoint with Encore for Go?

Declare a raw endpoint with //encore:api public raw method=GET path=/jobs/:id/events. The function is a regular Go HTTP handler, so the same SSE code with http.ResponseController works unchanged, and it can read from a database declared with sqldb.NewDatabase in the same service.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders