curl Was Happy. The Video Player Wasn't.
We shipped the private video delivery route for our client training portal. Tested it with curl. Got bytes back. Moved on.
Then we loaded the module player in a browser and the video sat there, buffering forever, showing no duration, refusing to scrub.
The Setup
The training portal stores video files in Vercel's private blob storage. Private means no public URL. You can't just point an <video src> at the file and serve it. To play the video, the browser has to hit a Next.js API route first, the route checks the session cookie, and if the auth passes, it proxies the blob stream to the client.
The first version of that route did what you'd expect: call get() on the blob store, get a stream back, return a 200 with the stream. curl confirmed the bytes were flowing. The route worked.
The browser disagreed.
What a Video Element Actually Does
A file downloader is happy with a 200. It reads bytes from the start, keeps going until it's done. curl is a downloader. A <video> element is not.
Browsers play video in a way that requires partial content support. Safari sends a Range: bytes=0-1 request before it will even begin playback. It's a probe: it asks for the first two bytes to verify the server will honor range requests, and if it gets back anything other than a 206, it stops. No 206, no video, no error message, just nothing.
Chrome is more tolerant about starting playback, but without range support, seeking is broken. The scrubber doesn't work because the browser can't jump to an arbitrary byte offset in a file it has to download linearly.
Both browsers need Content-Length to display duration and build the progress bar. Without it, the element doesn't know how long the video is, and the UI reflects that with a dead scrubber and a missing timestamp.
The initial route returned 200, no Content-Length, no Accept-Ranges header, and completely ignored any Range header the browser sent. curl handed it a passing grade. Safari gave it nothing.
The Fix
Vercel's get() function accepts arbitrary request headers. If you forward the Range header from the incoming browser request to get(), the blob store handles the partial content response natively. The result object then includes the real 206 status, the Content-Range header, and Content-Length from the upstream response.
The route now:
- Reads the
Rangeheader from the browser request. - Passes it through to
get()if present. - Checks whether the upstream returned a
Content-Rangeheader. - Returns a
206ifContent-Rangeis present, a200if the upstream sent the full body. - Includes
Accept-Ranges: bytesandContent-Lengthon every response so browsers know seeking is supported and can calculate duration.
The key detail: we trust the store's own status code rather than synthesizing one. Claiming 206 when the upstream actually returned the full body would break playback worse than not supporting range requests at all.
The Lesson
curl is the wrong tool for testing video delivery. It follows redirects, accepts any response code, and reads bytes regardless of headers. A browser video element is not a downloader. It's a player. It negotiates with the server before it plays anything, and the negotiation protocol is HTTP range requests.
If the server doesn't speak that protocol, the video element either refuses to start (Safari) or starts but gives the viewer a broken experience (Chrome). In both cases, nothing in the curl output would have told you.
Test video routes with a browser. Check that the scrubber works. Check that seeking mid-video works. Check that the duration shows. If any of those fail, you're missing range support somewhere in the chain.
The auth is only half the proxy. The streaming protocol is the other half.