Skip to content
A Bekkour
Writing

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.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing