Zulip-Style Long Polling
Most real-time chat applications immediately default to WebSockets. However, WebSockets introduce infrastructure friction;
Zulip-Style Long Polling
Most real-time chat applications immediately default to WebSockets. However, WebSockets introduce infrastructure friction;
they require specialized stateful load balancers, break standard HTTP caching layers, and demand complex reconnection logic when mobile clients switch networks.
Zulip circumvents this by utilising optimised HTTP Long Polling.
Instead of maintaining a raw, stateful TCP connection, the client makes a standard HTTP POST request to an endpoint, asking for events.
If no events exist, the server holds the connection open until a new message is published. The moment data is returned, the client processes it and instantly fires the next request.
The Event Queue Flow
Zulip’s long polling relies on ephemeral, memory-backed queues. When a client connects, it registers an event_queue_id on the server.
The client then requests events only from that specific queue, passing a last_event_id marker to ensure no messages are skipped or duplicated.
The Go Server Implementation
To implement this efficiently in Go, we leverage channels and select blocks. This allows the server to hold thousands of concurrent HTTP requests open using lightweight goroutines instead of blocking heavy OS threads.
Below is the complete, self-contained architecture for a Zulip-style event server.
package main
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
// Event represents a chat message or notification
type Event struct {
ID int64 `json:"id"`
Payload string `json:"payload"`
}
// EventQueue manages events for a single active user session
type EventQueue struct {
mu sync.Mutex
ch chan Event
lastSeenID int64
}
// QueueManager handles all active user sessions
type QueueManager struct {
mu sync.RWMutex
queues map[string]*EventQueue
}
func NewQueueManager() *QueueManager {
return &QueueManager{queues: make(map[string]*EventQueue)}
}
// GetOrCreateQueue registers an event queue for a session
func (qm *QueueManager) GetOrCreateQueue(id string) *EventQueue {
qm.mu.Lock()
defer qm.mu.Unlock()
if q, exists := qm.queues[id]; exists {
return q
}
q := &EventQueue{ch: make(chan Event, 100)} // Buffered to prevent blocking producers
qm.queues[id] = q
return q
}
// Broadcast sends an event to all active queues
func (qm *QueueManager) Broadcast(payload string) {
qm.mu.RLock()
defer qm.mu.RUnlock()
event := Event{ID: time.Now().UnixNano(), Payload: payload}
for _, q := range qm.queues {
select {
case q.ch <- event:
default: // Drop message if client queue is completely dead/full
}
}
}
func main() {
manager := NewQueueManager()
// 1. The Long Polling Endpoint
http.HandleFunc("/api/v1/events", func(w http.ResponseWriter, r *http.Request) {
queueID := r.URL.Query().Get("queue_id")
if queueID == "" {
http.Error(w, "Missing queue_id", http.StatusBadRequest)
return
}
q := manager.GetOrCreateQueue(queueID)
// Set a definitive server-side timeout (e.g., 30 seconds)
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
select {
case event := <-q.ch:
// Event arrived! Return it immediately to the client
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]Event{event})
case <-ctx.Done():
// Timeout reached with no new events. Return an empty array.
// This signals the client to safely reconnect without parsing data.
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("[]"))
}
})
// 2. The Message Ingestion Endpoint
http.HandleFunc("/api/v1/send", func(w http.ResponseWriter, r *http.Request) {
msg := r.URL.Query().Get("msg")
if msg != "" {
manager.Broadcast(msg)
}
w.WriteHeader(http.StatusOK)
})
http.ListenAndServe(":8080", nil)
}
The Client-Side Loop
The client application must run an endless, resilient loop. If the network drops or the server hits its 30-second timeout, the client should back off briefly and immediately establish the next poll request.
const QUEUE_ID = "user_session_abc123";
async function pollForEvents() {
while (true) {
try {
const response = await fetch(`/api/v1/events?queue_id=${QUEUE_ID}`);
if (response.status === 200) {
const events = await response.json();
if (events.length > 0) {
events.forEach(event => {
console.log("New Event Received:", event.payload);
// Render message in UI
});
}
// Reconnect instantly on successful data or normal timeout
continue;
}
// Handle server errors (e.g., 502, 503) with a short delay
await new Promise(resolve => setTimeout(resolve, 5000));
} catch (error) {
// Handle local network disconnects with exponential backoff
console.error("Network error, retrying in 10s...", error);
await new Promise(resolve => setTimeout(resolve, 10000));
}
}
}
// Start the loop
pollForEvents();
Production Trade-offs
Advantages
- Perfect Elasticity: Every poll is a standard stateless HTTP transaction. It passes cleanly through API Gateways, standard firewalls, and CDNs.
- HTTP/2 & HTTP/3 Native: Under HTTP/2, requests are multiplexed over a single TCP connection. The client avoids the connection setup overhead on subsequent polls.
Hello, I’m Lince. I’m working on git-lrc: a Git hook for reviewing AI-generated code.
메타데이터
- post_id
- b4e3b6c8ea71
- slug
- zulip-style-long-polling-b4e3b6c8ea71
- url
- https://medium.com/@linz07m/zulip-style-long-polling-b4e3b6c8ea71
- canonical_url
- https://medium.com/@linz07m/zulip-style-long-polling-b4e3b6c8ea71
- author_url
- https://medium.com/@linz07m
- status
- ok
- fetched_at
- 2026-06-14 14:02:58