You can put a camera in a room, sleep the screen, and watch the live feed from a laptop across the house — and do it without an account, a subscription, or a single frame leaving your Wi-Fi. The part people assume is trivial ("just open a socket and pipe the frames") is the part that quietly decides whether the whole privacy-first design holds together. This post is a deep-dive into one subsystem of Background Camera RemoteStream: the embedded web server that serves the live feed over your LAN, and the architectural decisions a no-cloud design forces on it.
I build the app, so I'll be specific about the choices. But almost none of this is app-specific magic — it's a set of trade-offs that land on anyone trying to serve live video from an Android phone to a browser on the same network, with no relay server in the middle. If you've touched Camera2, foreground services, or an embedded HTTP server on Android, this will be familiar terrain with a few edges worth pointing at.
If you want the wider context first — the screen-off capture problem, Doze mode, the MediaProjection consent dialog — I wrote a companion piece on the four hard parts of building a phone-as-camera app. This post assumes capture already works and zooms into the "watch it from another device" half.
Why an embedded server at all
The obvious way to let one device watch another is to put a server in the middle. The phone pushes frames up to your cloud, the viewer pulls them down. It's easy, it traverses NAT for free, and it's how most consumer camera apps work.
It's also the thing I specifically didn't want to build. A relay in the middle means every frame the camera sees passes through infrastructure I operate. That's a standing liability — for the user (their footage is now somewhere they don't control) and for me (I'm now the custodian of other people's living rooms). The whole premise of the app is that footage stays on the device. A cloud relay quietly breaks that premise no matter what the marketing says. I've written before about what happens to your footage when a cloud camera app shuts down or gets acquired — the short version is that "trust us" is a data-retention policy, not an architecture.
So the design constraint is: the viewer talks to the phone directly, over the local network, with nothing in between. On a home Wi-Fi network that's completely reasonable — both devices are already on the same subnet. The phone just needs to be a server. That's what the embedded HTTP layer is for.
The engine is Ktor with the CIO (coroutine I/O) engine, running inside the same foreground service that owns the camera. Ktor earns its place here for boring, correct reasons: it's pure Kotlin, it has no servlet-container baggage, its footprint is small enough to live comfortably inside an Android process, and — the part that actually matters for streaming — it's coroutine-native, so a long-lived response that dribbles out frames for ten minutes doesn't cost you a blocked thread.
The transport: MJPEG over multipart/x-mixed-replace
There's a menu of ways to get live video to a browser: WebRTC, HLS, a WebSocket pushing binary frames, or the oldest trick in the book — an MJPEG stream over a multipart HTTP response. For a LAN-only viewer whose main job is "let me glance at what the camera sees right now," MJPEG is the pragmatic winner, and it's worth being honest about why, including its downsides.
MJPEG works by never ending the HTTP response. You set the content type to multipart/x-mixed-replace; boundary=frame, and then you write JPEG after JPEG into the same response body, each preceded by a boundary marker and its own Content-Type/Content-Length headers. The browser renders each part as it arrives, replacing the previous one. A plain <img src="/stream"> tag displays a live video with zero client-side JavaScript. Conceptually the handler is just:
get("/stream") {
call.respondBytesWriter(contentType = ContentType.parse("multipart/x-mixed-replace; boundary=frame")) {
while (streamActive) {
val jpeg = frameBus.awaitLatest() // suspends until a new frame is ready
writeStringUtf8("--frame\r\n")
writeStringUtf8("Content-Type: image/jpeg\r\n")
writeStringUtf8("Content-Length: ${jpeg.size}\r\n\r\n")
writeFully(jpeg)
writeStringUtf8("\r\n")
flush()
}
}
}
The reasons to choose this over something fancier:
It has no negotiation and no server-side state machine. WebRTC is the "correct" modern answer for low-latency video, but it drags in ICE, STUN/TURN, SDP negotiation, and a media stack — a lot of moving parts whose main purpose is getting through NAT between two networks. On a single LAN you don't have a NAT problem to solve, so you'd be paying WebRTC's complexity tax for a benefit you don't need.
It degrades gracefully. Each frame is an independent JPEG. There's no inter-frame dependency, so a dropped or late frame costs you exactly one frame — never a smeared keyframe cascade the way a broken H.264 GOP does.
Any browser, any decade, renders it. No codec support questions, no MediaSource juggling.
And the honest costs, because a deep-dive that only lists advantages is a sales page:
- Bandwidth is high. Every frame is a full JPEG; there's no temporal compression. On a LAN that's usually fine, but it's the reason MJPEG is a bad idea the moment you're tempted to route it over the internet.
- Latency is real but modest — you're a frame or two behind, which is fine for monitoring and wrong for anything interactive.
- It's one-way. Fine here; the viewer is watching, not talking back.
The frame source is the capture pipeline handing off already-encoded JPEGs (an ImageReader in JPEG format is the path of least resistance from Camera2, and it keeps the server layer dumb — the server never touches YUV or does color conversion, it just relays bytes).
The trap: fan-out and backpressure
Here's the bug that doesn't show up until a second viewer connects.
The naive mental model is "the camera produces frames, the HTTP handler writes them." But you can have zero, one, or three viewers, and the camera is producing frames on its own clock regardless. If you let each HTTP response pull directly from the camera, you've coupled the capture rate to the slowest reader on the network. One viewer on a weak Wi-Fi signal, whose socket write blocks, will back up the queue and stall the camera pipeline for everyone — including the on-device recording, which must never hitch.
The fix is a single-producer, multi-consumer hand-off with a deliberately shallow buffer. The camera pushes each new JPEG into a shared holder — conceptually a Kotlin StateFlow or a conflated channel that holds only the latest frame. Each connected /stream handler is an independent consumer that reads the latest value when it's ready to write. If a slow viewer misses frames because it couldn't keep up, that's correct behavior: for a live monitor you always want the newest frame, never a backlog of stale ones. A conflated buffer gives you exactly that — drop-oldest, keep-latest — for free.
This is the single most important design decision in the whole subsystem, and it's invisible in a one-viewer demo:
- Capture is decoupled from delivery. The camera and the recorder run at their own rate; the server is a best-effort tap on the side.
- Slow clients degrade themselves, not the system. A struggling viewer sees a lower frame rate. Nobody else notices.
- Recording is never held hostage by the network. The on-device recording is the product's real job; a viewer stalling a write must not be able to touch it.
If you take one thing from this post, take this: the moment your live feed can have more than one consumer, the frame buffer between producer and consumers is where correctness lives. Get the conflation policy right and everything downstream is easy.
Discovery: how does the viewer find the phone?
The server is listening on the phone's LAN IP on some port. Now the human has to point a browser at it, and "go find your phone's DHCP-assigned IP address" is not an instruction real people can follow.
Two things make this humane. First, the app shows its own URL directly on screen — the current http://<lan-ip>:<port> — so the address is right there to type or scan from a QR code, no router admin page required. Second, and better, is NsdManager, Android's wrapper around mDNS/DNS-SD (the same Bonjour/zeroconf machinery that lets printers announce themselves). The service registers a _http._tcp record with a friendly instance name, and a viewer that speaks mDNS can resolve a stable .local-style name instead of chasing a numeric IP that changes every time the DHCP lease renews.
The sharp edge: mDNS reliability is a property of the network, not your code. Plenty of consumer routers — especially with "AP isolation" or "client isolation" enabled for guest networks — silently drop multicast between clients. When that happens, discovery fails in a way that looks like your bug but isn't. The lesson learned the hard way: always keep the dumb fallback. The on-screen raw IP has to work even when every clever discovery mechanism is blocked, because on someone's network, it will be.
Security on a LAN is not "no security"
"It's only on the local network" is where a lot of hobby projects quietly stop thinking, and it's the wrong place to stop. Your LAN is not a trusted room. It has guests, it has the neighbor who has your Wi-Fi password from that one time, it has IoT devices you forgot you owned. A live camera feed reachable by anyone on the subnet with a browser is a real exposure.
The decisions that follow from taking that seriously:
Bind to the Wi-Fi interface, and gate the loud paths. The server should be reachable on the LAN but should never be trivially open to every device that happens to be on it. A shared access token in the URL/path, or a lightweight challenge before the stream opens, is the difference between "my household can view this" and "anyone on the coffee-shop Wi-Fi can."
Plain HTTP is a genuine limitation, and I won't pretend otherwise. Serving TLS from an ephemeral device means either a self-signed certificate (browser warnings that train users to click through security prompts — a bad habit to teach) or a real cert for a name the device doesn't own. On a trusted home LAN, plain HTTP to a device you physically control is a defensible trade-off. On a network you don't trust, it isn't, and the app's honest position is that LAN viewing is a same-network convenience, not a hostile-network security tool. Design integrity means naming the boundary, not hiding it.
No feature should ever tempt the user off-LAN insecurely. The single most dangerous thing this subsystem could do is make it easy to punch a port through the router to "watch from work." That turns a reasonable LAN server into an unauthenticated camera on the public internet — the exact class of device that ends up indexed on Shodan. The safe remote path is a fundamentally different mechanism: an unlisted YouTube Live stream, which I walk through end-to-end here. Push-to-a-broadcast is safe to expose; a pull-from-my-house port is not. Keeping those two paths architecturally separate is a deliberate guardrail, not an accident.
Living inside a foreground service
The last constraint ties back to the rest of the app: this server runs inside the same foreground service that keeps the camera alive with the screen off. That placement is doing real work.
The service holds the process above the reach of the OS's aggressive background-kill behavior, which means the listening socket stays open through screen-off and Doze. It also gives the server a single, honest lifecycle: it starts when monitoring starts, it stops when monitoring stops, and its onDestroy is the one place responsible for closing the Ktor engine and tearing down the mDNS registration so you don't leak a half-open server or a stale service advertisement. Binding the server's lifetime to the foreground service — rather than to an Activity that Android can and will destroy the instant the screen sleeps — is what makes "server keeps serving while the phone sits dark on a shelf" actually true.
The cost is battery and heat, and the mitigation is to do as little as possible in the hot path: the server does no encoding, no scaling, no format conversion. It relays already-encoded bytes from a conflated buffer and nothing more. Every CPU cycle you don't spend per frame is one the device isn't spending 20+ times a second, and on a phone running for hours that adds up.
What the subsystem is, and isn't
Put together, the LAN server is deliberately small: a Ktor CIO engine inside the foreground service, an MJPEG stream over multipart/x-mixed-replace, a conflated single-latest-frame buffer that decouples capture from delivery, mDNS discovery with a raw-IP fallback that always works, and a security posture honest about being a same-network convenience.
What it is not is a hardened, internet-facing surveillance appliance, and the architecture is built so you can't accidentally turn it into one. That restraint is the point. A no-cloud design doesn't get you privacy for free — it moves the responsibility onto boundaries you draw yourself: keep footage on the device, keep the live feed on the LAN, keep the internet-facing path (broadcast, not pull) separate and explicit. Every one of those is a "no" to a feature that would have been easy to add and expensive to trust. If you want to see how the same reasoning plays out at the level of "can this actually replace my cloud camera," I wrote an honest version of that comparison here.
Background Camera RemoteStream is on Google Play, and the wider project lives at superfunicular.com. If you're building something in this space and want to compare notes on the frame-buffer or discovery edges, the comments are the right place — those are the parts I'd most like to hear other people's war stories about.
Top comments (0)