DEV Community

Super Funicular
Super Funicular

Posted on

The Range Header Is the Whole Feature: Serving Recorded Video From a Phone's Own Web Server

If you put an embedded HTTP server inside an Android app so that another device on the
same Wi-Fi can watch the camera in a browser, you will build the live view first. It is
the part everybody asks for, and the part that demos well.

Then someone asks to watch a recording from last night, and you reach for the easy win.
The live view is a moving target: frames arriving from an encoder, a connection that has
to stay open, backpressure, a client that might be on a slow radio. A recording, by
comparison, is a file. It has already finished. It is sitting on the device's storage with
a known size and a known type. Serving it should be strictly less work than serving the
live view.

It is not. Over HTTP, a finished file is the harder of the two, and the reason is a
single request header that the live view never needed and never will.

The two jobs ask for different things from a server

The live view asks a server to answer one question forever: what is happening now. The
client connects once and the bytes keep coming. Nobody asks for the middle. Nobody asks
how long it is, because it does not have a length — it ends when the camera stops or the
browser tab closes. A server can answer that with chunked transfer encoding, with
multipart JPEG, with a segmented format, and in every case it never has to state a
Content-Length, because there is no total to state.

A recording asks a different question, and asks it repeatedly: give me the part of this
that I am about to display.
A browser handed a one-hour file does not want the hour. It
wants a few hundred kilobytes to get started, and then, the instant a human drags the
scrubber to 47 minutes, it wants the bytes that live at 47 minutes and nothing before
them.

That request has a name. It is the Range header, and if your server does not implement
it, seeking either does not work or costs the entire file every time.

What the exchange actually looks like on the wire

The client sends a normal GET with one extra line:

GET /recordings/2026-09-15T2214.mp4 HTTP/1.1
Host: 192.168.1.42:8080
Range: bytes=0-1023
Enter fullscreen mode Exit fullscreen mode

A server that supports ranges does not reply 200 OK. It replies
206 Partial Content:

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 0-1023/2147483648
Content-Length: 1024
Content-Type: video/mp4
Enter fullscreen mode Exit fullscreen mode

Three things are being communicated there, and each one matters to the player:

  • Accept-Ranges: bytes advertises that this resource can be requested in pieces at all. Per MDN, any value other than none means range requests are supported. Its absence is an answer too.
  • Content-Range states which bytes these are and the size of the whole resource — the /2147483648 at the end. That total is how the player learns the duration is worth drawing a scrubber for.
  • Content-Length now describes the slice, not the file.

A client may also ask for several ranges in one request, in which case the response body
becomes multipart/byteranges with each part carrying its own Content-Range. Most
video players do not do this; download managers do.

The failure mode is quiet. A server that does not know about Range does not error — it
ignores the header and returns 200 with the entire body, which is a legal response and
looks fine in a log. You will see a wall of 200s, full-size transfers, and a scrubber
that either does nothing or stalls for ten seconds per drag.

Ktor will not do this for you by default

If the embedded server is Ktor — which it is in our case, and in a lot of Android
projects that need an HTTP surface without shipping a whole app server — range support is
an explicit install, not a default:

install(PartialContent)
install(AutoHeadResponse)

routing {
    staticFiles("/recordings", recordingsDir)
}
Enter fullscreen mode Exit fullscreen mode

The PartialContent plugin carries
three constraints that are easy to trip over on a device:

  1. It applies to GET and HEAD only. A Range header on any other method gets 405 Method Not Allowed. That is correct behaviour, and it is also a good reason to keep the recordings route boringly read-only.
  2. It only works on responses that already have a Content-Length. If you are streaming a file through a channel that does not know its own size, the plugin has nothing to compute an offset against and will not engage. Serve recordings from something that can state its length.
  3. It disables compression on ranged responses. This is the right trade and worth understanding rather than working around: gzipping a byte range would make the offsets in Content-Range describe the compressed stream, not the file, and the client's next seek would land in the wrong place. H.264 in an MP4 container is already compressed; there was nothing to win here anyway.

AutoHeadResponse is in that snippet for a reason. Several players — and every
diagnostic tool you will reach for — probe with HEAD before they commit to a download.
If HEAD 404s because you only registered a GET route, the client concludes the
resource is not there and never sends the range request you carefully implemented.

The second half of the problem is inside the file

Range support is necessary. On a phone it is frequently not sufficient, because of where
the MP4 container puts its index.

An MP4 is roughly two things: mdat, the media payload, and moov, the table that says
what codec the payload uses, where each frame starts, and what timestamp it carries. A
player cannot decode a single frame of mdat without moov — it does not know the frame
boundaries.

moov can only be finalised when recording stops, because until then the recorder does
not know how many frames there will be. The straightforward way to write the file is
therefore to append moov at the end. For a file you open locally this is invisible; the
filesystem seeks to the end in microseconds. Over HTTP it is the entire ballgame. A
player reading progressively from byte zero gets the payload it cannot interpret and
keeps reading, waiting for an index that arrives only after the last byte of a
multi-gigabyte file.

This is where the two halves meet: a player can only skip ahead to fetch the trailing
index if the server supports range requests.
Given Accept-Ranges: bytes, a browser
asks for the tail, finds moov, and starts playing in a second. Without it, the same
file on the same network takes as long to start as it takes to download in full — and a
24/7 camera writes files that are
large enough for that to be measured in minutes, not seconds.

The permanent fix, where you control the muxing pipeline, is to relocate the index to the
front of the file — the transformation FFmpeg exposes as -movflags +faststart, which
rewrites the file with moov ahead of mdat. Note that it is a genuine second pass over
the bytes, which on a phone is not free: it is real I/O against storage that is already
being written to continuously.

A disambiguation worth stating, because these two get conflated constantly: this is not
the case where a recording was interrupted and the index was never written at all. Here
the file is complete and healthy. Every byte is present and correct. It simply arrives in
an order that is wrong for a network.

Safari is the strictest client, and that is useful

If you only ever test in Chrome on a laptop, you will ship a server that half-works.

Safari sends a small opening range request before it will commit to a <video>
source
— in practice a request for the
first couple of bytes — and if what comes back is a 200 rather than a 206 with proper
range headers, it moves on to the next source in the list. If every source answers the
same way, it renders nothing. No error in the page, no half-loaded player: an empty
element.

Treat this as a free conformance test rather than a Safari quirk. On a LAN camera the
client population is every browser in the household — an iPad in the kitchen, an old
Android tablet, someone's work laptop with a managed policy. Safari is simply the one
that tells you immediately.

How to check it in thirty seconds

From any machine on the same network, ask the server for a slice and look only at the
status line:

curl -s -D - -o /dev/null -r 0-1023 http://192.168.1.42:8080/recordings/clip.mp4
Enter fullscreen mode Exit fullscreen mode
  • 206 Partial Content with a Content-Range — working.
  • 200 OK with the full Content-Length — the header was ignored. Seeking will appear to work in Chrome while transferring the whole file, and will not work at all in Safari.
  • 405 — you sent the range on the wrong method, or a proxy rewrote it.

Then do the same with -I to confirm HEAD is answered at all.

Why this bites harder on a phone than on a CDN

Every hosted video platform solved this years ago, which is why it is easy to forget it
is a problem. On a device-hosted server the constraints are meaner in three specific
ways.

The files are bigger relative to the pipe. A LAN link is fast but the phone's radio is
shared with everything else in the house, and
the numbers Android actually negotiates are often well below the figure on the router's
box
.
Sending a two-gigabyte file to satisfy a scrubber drag is not a rounding error there.

The server and the camera are the same device. Bytes you send unnecessarily are bytes
read from the same storage the recorder is writing to, using the same CPU that is running
the encoder, on a battery that is also charging. A naive full-file response is not just
slow; it competes with the job the phone is actually there to do.

And there is no operations team. Whoever installed the app is the sysadmin, and the only
symptom they will ever report is "the video does not work on my iPad." Getting the
protocol right is how you avoid ever having that conversation.


If you are building something similar, the longer write-up of the live-view side —
which is a genuinely different problem with a genuinely different shape — is here:
How an Android Phone Serves Its Own Live Camera Feed Over Your LAN: An Embedded Ktor Server Deep-Dive.
And if you want the other classic way a phone-hosted camera goes quiet without erroring,
Android's camera foreground service type does not cover the microphone.

Background Camera RemoteStream records with the screen off, keeps footage on the
device rather than in anyone's cloud, serves a live view from a built-in web server to
any browser on your own network, and can stream to YouTube Live. No account, no
subscription. Built in Kotlin on Camera2 with an embedded Ktor server, across 75+
AI-assisted development sessions.

If you have implemented ranged playback against a device-hosted server and hit something
that is not in here, I would like to read about it in the comments.

Top comments (0)