Skip to content
gohacks.dev
Web

Long Polling in Go and Where It Still Makes Sense to Use

Implement long polling in Go with a cursor, a wait that ends on publish, timeout or disconnect, and learn when it beats WebSockets and server sent events.

8 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Long polling is the technique people write off as obsolete and then reach for again when a corporate proxy eats their WebSocket. It is also the easiest of the three live-update options to reason about, because every exchange is one ordinary request and one ordinary response. There is nothing to upgrade and nothing to keep alive, and a Go handler that does it is short enough to read in one sitting.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds the server side with a cursor, shows the three ways the wait ends, and says where I would still pick it. It belongs to the web cluster that ends in a production HTTP server, next to server sent events and WebSockets. Everything ran on Go 1.25.6 with the standard library.

The protocol is a loop

The technique is old enough to have its own informational RFC, RFC 6202, which catalogues the pitfalls this article walks through. MDN's guide to connection management in HTTP/1.x page explains how idle connections are reused and closed, which is the reason the wait has to be bounded.

The client asks "anything after 17?" The server checks. If there is something, it answers at once. If not, it parks the request, and answers when something shows up or when a timeout expires, whichever is first. The client processes the answer and immediately asks again with the new cursor. In pseudocode on the client:

Text
cursor = 0
loop:
    response = GET /poll?since=cursor   (waits up to 25 s on the server)
    if response.status == 200: handle(response.events); cursor = response.next
    if response.status == 204: nothing new; ask again

The cursor is what separates a real implementation from a toy. Without it, anything published between the moment one response arrives and the next request is sent is lost. With it, the client says exactly what it has seen, and the server answers from there, so the gap is harmless. The cursor can be a sequence number, a row ID or a timestamp with a tie-breaker, as long as it is monotonic and the server can query "after this".

A server with three ways out

main.goGo
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"sync"
	"sync/atomic"
	"time"
)

// Feed is an append-only list of events and the requests waiting for the next one.
type Feed struct {
	mu      sync.Mutex
	events  []string
	waiters map[chan struct{}]struct{}
}

func NewFeed() *Feed { return &Feed{waiters: map[chan struct{}]struct{}{}} }

func (f *Feed) Publish(e string) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.events = append(f.events, e)
	for ch := range f.waiters { // wake everyone; closing is a broadcast
		close(ch)
	}
	f.waiters = map[chan struct{}]struct{}{}
}

func (f *Feed) Waiting() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return len(f.waiters)
}

// Wait returns events after the cursor. It blocks until there are some, the timeout passes, or ctx ends.
func (f *Feed) Wait(ctx context.Context, since int, timeout time.Duration) ([]string, int) {
	f.mu.Lock()
	if since < len(f.events) {
		defer f.mu.Unlock()
		return append([]string(nil), f.events[since:]...), len(f.events)
	}
	ch := make(chan struct{})
	f.waiters[ch] = struct{}{}
	f.mu.Unlock()

	timer := time.NewTimer(timeout)
	defer timer.Stop()
	select {
	case <-ch: // a Publish happened
	case <-timer.C:
	case <-ctx.Done():
	}

	f.mu.Lock()
	defer f.mu.Unlock()
	delete(f.waiters, ch) // harmless if Publish already replaced the map
	if since < len(f.events) {
		return append([]string(nil), f.events[since:]...), len(f.events)
	}
	return nil, since
}

func pollHandler(f *Feed, timeout time.Duration) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		since, err := strconv.Atoi(r.URL.Query().Get("since"))
		if err != nil || since < 0 {
			http.Error(w, "since must be a non-negative integer", http.StatusBadRequest)
			return
		}
		events, next := f.Wait(r.Context(), since, timeout)
		if len(events) == 0 {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(map[string]any{"events": events, "next": next})
	}
}

func main() {
	feed := NewFeed()
	var served atomic.Int64
	srv := httptest.NewServer(pollHandler(feed, 100*time.Millisecond))
	defer srv.Close()

	poll := func(ctx context.Context, since int) {
		start := time.Now()
		req, _ := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/?since=%d", srv.URL, since), nil)
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Printf("since=%d: client gave up (%v)\n", since, ctx.Err())
			return
		}
		defer res.Body.Close()
		var body struct {
			Events []string
			Next   int
		}
		json.NewDecoder(res.Body).Decode(&body)
		served.Add(1)
		slow := time.Since(start) >= 90*time.Millisecond
		fmt.Printf("since=%d: status=%d events=%v next=%d waited-full-timeout=%v\n",
			since, res.StatusCode, body.Events, body.Next, slow)
	}

	poll(context.Background(), 0) // nothing yet: times out with 204

	go func() { time.Sleep(30 * time.Millisecond); feed.Publish("deploy started") }()
	poll(context.Background(), 0) // woken by the publish, well before the timeout

	feed.Publish("deploy finished")
	poll(context.Background(), 1) // data already there: answers immediately
	poll(context.Background(), 2) // caught up again: 204 after the timeout

	// A client that disconnects must not leave a waiter behind.
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	poll(ctx, 2)
	time.Sleep(20 * time.Millisecond)
	fmt.Println("waiters left after the client gave up:", feed.Waiting())
}
Text
since=0: status=204 events=[] next=0 waited-full-timeout=true
since=0: status=200 events=[deploy started] next=1 waited-full-timeout=false
since=1: status=200 events=[deploy finished] next=2 waited-full-timeout=false
since=2: status=204 events=[] next=0 waited-full-timeout=true
since=2: client gave up (context deadline exceeded)
waiters left after the client gave up: 0

The Wait method has the structure worth copying. It checks for data first, under the lock, so an event published before the request arrived is never missed. Only if there is nothing does it register a waiter, and it registers before releasing the lock, which closes the window where a Publish could slip in between "no data" and "now waiting" and leave the request sleeping through its own wake-up. Then it waits on three things at once. The channel ch fires when something is published. The timer fires when the server decides the poll has gone on long enough. And ctx.Done() fires when the client hangs up. When any of them wins, it removes its waiter and looks at the data one more time under the lock.

Closing a channel to wake every waiter is a broadcast that costs nothing per waiter, and it is the same trick as a done channel. The alternative, a channel per waiter that Publish sends on, forces Publish to deal with waiters that already left.

The last two lines of the output are the ones to check when you write your own. A client that gives up after 20 ms leaves 0 waiters behind, because the context branch removed it. Forgetting that branch is the classic long-polling leak: a mobile user on a bad network opens and abandons connections all day, and every abandoned request keeps a goroutine, a map entry and a channel for as long as the server timeout. With a 25 second timeout and a thousand flaky users, that is a lot of dead weight, and it does not show up in a test unless you cancel a request in the middle of a wait.

Timeouts, proxies and the polling interval

The server's wait must be shorter than anything in the path that closes idle connections. The usual suspects are 60 seconds on nginx (proxy_read_timeout), 30 or 60 on cloud load balancers, and 100 seconds on some CDNs. I use 25 seconds, which leaves room under all of them, and have the client treat any network error as "wait a second and retry" rather than as a failure. Add jitter to that retry. If a deploy restarts the server, ten thousand clients reconnecting at the same instant is a self-inflicted outage, and a random 0 to 2 second delay turns it into a ramp.

On the Go side, a long handler runs into the same settings as an event stream: a global WriteTimeout shorter than the wait will cut responses off, and Server.Shutdown will wait for the parked requests. Both are covered in the timeouts article and graceful shutdown for the HTTP server. For shutdown, the simplest fix is a channel the handler also selects on, so parked polls return 204 immediately and clients reconnect to the new instance.

When I would still use it

Long polling costs more per message than the alternatives, since every event is a full request with headers (a few hundred bytes of overhead, sometimes a TLS handshake after an idle period). Where updates are frequent, say several a second, that overhead is wasteful, and a persistent stream is better. Where updates are rare, such as "your export is ready" or "a new comment arrived", polling for 25 seconds at a time costs next to nothing and works everywhere.

The cases where I pick it deliberately:

  • Hostile networks. Some corporate proxies buffer or terminate streaming responses and drop upgrade requests. A plain request that completes always passes through.
  • Simple infrastructure. Standard load balancing, standard logging, standard auth middleware, standard cookies. Nothing special at any layer.
  • A fallback. A client library that tries WebSockets or SSE first and drops to long polling is how products with a large range of customers' networks stay reliable.

And where I would not: a chat room, a game, live cursors, a trading screen. Those want a WebSocket and its explicit backpressure. For one-way streams that need browser reconnect logic for free, server sent events are the better default.

One more number to keep in mind: a held request is a goroutine plus a socket. Go handles tens of thousands of those comfortably, but check the file descriptor limit before you count on it.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.