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.
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.
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.
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.
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.
Run in your terminal to get started locally.
Create an app from the Go hello-world template and add a jobs service:
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 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.
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.
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.
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.
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.