Streaming uploads over WebSockets in Go with io.Pipe backpressure
6 min read
I build a tool that records a customer’s screen to reproduce a bug. The browser’s MediaRecorder
hands you a chunk of video roughly once a second, for up to ten minutes, and that has to end up in
object storage. The obvious implementation — collect all the chunks, then upload the file — falls
over in two ways at once: a ten-minute recording is tens of megabytes held in RAM per concurrent
session, and if your storage write is slower than the browser produces data, you have no way to
tell the browser to slow down. You just grow the buffer until something dies.
The fix is to never hold the whole file at all: pipe each chunk straight from the WebSocket into
the storage writer, and let the pipe apply backpressure for you. In Go, io.Pipe makes this almost
too clean. Here’s the whole design.
The core idea: io.Pipe between the socket and storage
io.Pipe() returns a connected reader/writer pair. Whatever you write to the PipeWriter comes out
of the PipeReader — and it’s synchronous: a Write blocks until a Read consumes those bytes.
That property is the entire trick.
I start a goroutine that hands the PipeReader to storage’s Put (which streams it up), and the
WebSocket read loop writes each incoming chunk into the PipeWriter:
startVideoPipe := func() {
if pipeStarted {
return
}
pipeStarted = true
pr, pw = io.Pipe()
countingReader := &countingPipeReader{r: pr, count: &bytesReceived}
uploadCtx, cancelUpload = context.WithCancel(context.Background())
wg.Add(1)
go func() {
defer wg.Done()
uploadedURL, uploadErr = h.store.Put(uploadCtx, recordingID+mediaExt, countingReader, mediaCT)
_ = pr.Close()
}()
}
Now trace what happens when storage is slow. store.Put stops reading from the pipe → the next
pw.Write in the read loop blocks → the read loop stops calling conn.ReadMessage → the OS TCP
receive buffer fills → TCP’s own flow control tells the browser’s socket to stop sending. On the
server, the socket buffer is the backpressure: memory stays flat no matter how slow the storage
or how big the recording, because nothing is ever buffered in the process — bytes flow straight
through the pipe.
Be precise about how far that reaches, though, because an interviewer will. The backpressure stops at
the browser’s socket buffer, not at MediaRecorder. The recorder keeps emitting chunks on its
timer; this client doesn’t gate sends on ws.bufferedAmount or pause the recorder, and it also keeps
a copy of each chunk to build a final blob. So the browser’s memory can still grow on a long
recording — it’s the server that’s protected. True end-to-end backpressure would mean watching
bufferedAmount and pausing MediaRecorder, which is a real next step I haven’t taken yet.
The io.Reader seam has one more payoff: store.Put doesn’t care what’s behind it. Right now the
only implementation writes to local disk; an S3 backend drops in without touching a line of the
streaming code, which is the whole reason storage sits behind a reader.
One socket, two kinds of frame
The same WebSocket carries two things: the binary media chunks, and JSON control messages (the recording’s metadata, the captured console/network events, and a “finish” signal). WebSockets already distinguish binary from text frames, so the read loop demultiplexes on message type — no custom framing protocol needed:
for {
mt, payload, err := conn.ReadMessage()
if err != nil {
logger.Info("ws read ended", "err", err)
break
}
conn.SetReadDeadline(time.Now().Add(10 * time.Minute))
switch mt {
case websocket.BinaryMessage:
if !pipeStarted {
startVideoPipe()
}
if _, err := pw.Write(payload); err != nil {
logger.Error("write video chunk failed — aborting", "err", err)
_ = conn.WriteJSON(map[string]any{"status": "error", "message": "storage write failed"})
return
}
case websocket.TextMessage:
var env envelope
if err := json.Unmarshal(payload, &env); err != nil {
continue
}
switch env.Kind {
case "meta": meta = env.Data
case "events": events = env.Data
case "finish": finished = true
}
}
if finished {
break
}
}
The pipe is created lazily on the first binary chunk, so a session that only ever sends control frames never allocates one. Two limits guard the loop: a read size limit so a single frame can’t blow up memory, and a rolling deadline reset on every message so an idle or hung connection can’t hold a goroutine forever:
conn.SetReadLimit(64 * 1024 * 1024)
conn.SetReadDeadline(time.Now().Add(10 * time.Minute))
The matching client is boringly symmetric — binary for chunks, JSON envelopes for everything else:
sendVideoChunk(chunk: ArrayBuffer | Blob): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send(chunk); // binary frame
}
sendMeta(meta: unknown): void {
this.sendJSON({ kind: 'meta', data: meta }); // text frame
}
async finish(): Promise<UploadResult> {
this.sendJSON({ kind: 'finish', data: null });
return this.finishPromise; // resolves when the server sends {status:"ok"}
}
Shutting down without truncating a file
Streaming makes the happy path easy and the unhappy path the interesting part. Two failure modes
matter, and both come down to one question: did we receive the finish signal?
Slow or stuck storage. When the loop ends, I close the writer (which lets store.Put see EOF and
finish), then wait for the upload goroutine — but with a timeout, so a wedged storage backend can’t
hang the handler. If it blows the deadline, I cancel its context and record the error:
if pipeStarted {
_ = pw.Close()
doneCh := make(chan struct{})
go func() { wg.Wait(); close(doneCh) }()
select {
case <-doneCh:
case <-time.After(5 * time.Minute):
cancelUpload()
<-doneCh
uploadErr = errors.New("storage timeout")
}
}
A dropped connection. If the browser disconnects mid-recording, we never got finish — which
means whatever reached storage is a truncated file. A half-written video that looks like a real
recording is worse than no recording. So no finish means discard:
if !finished {
logger.Warn("ws closed without finish — discarding partial upload", "bytes_received", bytesReceived)
_ = h.store.Delete(context.Background(), recordingID+mediaExt)
return
}
Only once the media is safely stored do I write the sidecar JSON (events, metadata) and fire the
webhook to the app, then send {status:"ok"} back down the still-open socket so the browser knows
the recording is durable. The client’s finish() promise resolves on exactly that message.
One small nicety: the byte counter is a reader that wraps the pipe and increments atomically, so I get an accurate size without buffering anything to measure it:
func (c *countingPipeReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if n > 0 {
atomic.AddInt64(c.count, int64(n))
}
return n, err
}
If you’re uploading anything large or long-lived, don’t buffer it in your process — stream it, and
make sure the slow end can push back on the fast end. io.Pipe gives you that on the server side
almost for free: wire the socket read loop to the storage writer through a pipe and TCP flow control
handles the rest. Then spend your actual effort on the shutdown path — the storage timeout, and
discarding anything that didn’t cleanly finish — because with streaming, “what happens when it
breaks halfway” isn’t an edge case, it’s the whole design.
This is how uploads work in Constat, a bug-capture tool I’m actively building — one link records a customer’s screen, console and network so support can replay the exact repro. If you’re doing streaming or WebSocket work in Go and want a second pair of eyes, get in touch. The client side of this — capturing the console and network — is its own post: capturing a bug in the browser.