Every live streaming system I know of is built around a media server. OLOS replaces it with a bucket and a small coordinator.
OLOS, short for Open Live Object Streaming, is a draft protocol for low-latency live streaming on top of commodity object storage. The whole design fits in one sentence. Object storage holds the media bytes, and a small trusted coordinator decides which of those bytes are part of the live stream.
Inspiration
Cloudflare R2 is cheap, HTTP-native, globally cacheable, and works with Workers. It is also only object storage. It stores and serves bytes, and it has no idea what a live stream, a playback position, or a live window is.
I started on this after watching a video from @developedbyed that stored the HLS segments of a video in R2 instead of a single .mp4. I wondered whether R2 could carry a live stream, and a quick prototype showed that it could, in near real time.
The more I worked on the prototype, the less the interesting part was R2, or even video. The interesting part was the boundary between untrusted uploaded objects and official live stream state. So I turned the prototype into a protocol.
The problem
Object storage is good at storing and serving immutable files. A live stream needs more than files. It needs state: where the live edge is, which objects are part of the stream, which are still pending, and which are old enough to fall out of the window.
Media servers, packagers, and origins hold that state today. They work, and they are expensive to run.
The naive alternative, “stream from a bucket”, is dangerous. If the publisher can choose object keys, overwrite media, upload playlists, or make readers infer stream state from bucket listings, then the publisher defines the stream. Object storage must hold the bytes without defining the stream.
Objects and live state
OLOS represents a live stream as an ordered sequence of immutable objects. Only a trusted coordinator decides which objects are part of the stream.
Architecture
For video, the objects are CMAF init segments, parts, and media segments. The protocol splits everything into two halves:
- Objects: immutable bytes in object storage.
- Live state: the authoritative record of which objects are live, in what order, and for how long. The coordinator owns it.
Objects are inert. Uploading one grants it no status, and it can sit in the bucket forever without becoming part of the stream. A publisher only ever does one thing. It uploads objects to a location the coordinator gave it. Whether those objects count, when they appear in the manifest, and when they fall out of the window is live state, and live state never lives in the bucket.
Every object moves through a small, one-way state machine:
Slot state machine
A slot is issued when the coordinator hands out an upload location. It becomes upload_observed once the coordinator has seen the object land in storage. It becomes committed only after the coordinator has checked the object and folded it into the stream. A slot can also fall to expired, rejected, or revoked, but it never skips ahead. Nothing is committed without first being observed.
The life of an object
An uploaded object becomes part of the stream in four phases, and each one keeps the coordinator from having to trust the publisher:
- The publisher asks the coordinator for an upload slot.
- The publisher uploads the object directly to object storage.
- The coordinator observes the object that landed.
- The coordinator commits the object and advances the live edge.
The publisher talks to the coordinator twice, once for the slot and once to commit. The media itself never passes through the coordinator.
The life of an object
Slots and upload grants
A slot is a promise about exactly one object: this key, this content type, these byte bounds, valid until this moment. The coordinator records it as issued and hands back a short-lived, presigned upload grant.
The publisher never chooses the key. In a direct-public deployment, the coordinator derives it from the object’s intent (rendition, media sequence, part number, kind) plus a fresh 16-byte nonce. Future object URLs can’t be guessed by anyone watching the stream.
For an S3-compatible store, OLOS presigns a single PutObjectCommand bound to the exact key, the declared content type, and a create-if-absent condition:
TypeScript
const command = new PutObjectCommand({
Bucket: options.bucket,
ContentType: options.slot.contentType,
IfNoneMatch: "*",
Key: options.slot.objectKey,
Metadata: { "olos-slot-id": options.slot.id },
});IfNoneMatch: "*" means the upload can only create an object, never overwrite one. A publisher can’t replace committed media, pick a different key, or upload after the grant expires. The slot’s ID is stamped into the object’s metadata, so the coordinator can later check that the object it observes is the one it issued the slot for.
With the grant in hand, the publisher uploads straight to storage:
TypeScript
const upload = await fetch(grant.uploadUrl, {
body: grant.bytes,
headers: grant.requiredHeaders,
method: "PUT",
});grant.requiredHeaders carries the content type, If-None-Match, and the slot ID metadata. If the PUT succeeds, the object exists in the bucket. As far as the stream is concerned, nothing has happened yet.
Observing the upload
The publisher then tells the coordinator it’s done. The coordinator doesn’t take its word for it.
Instead, it observes the object by looking at what landed in storage. For S3, that is a HeadObject call, which returns the real content length, content type, etag, and metadata:
TypeScript
const output = await client.send(new HeadObjectCommand({ Bucket, Key }));This is where the protocol stops trusting the publisher. The size checked against the slot’s bounds is the observed size, not the number the publisher claimed. The content type is the value storage recorded, not one supplied to the commit.
Committing
Commit is where an observed object becomes part of the stream, and where most of the protocol’s rules live. Before the object is folded into live state, the commit has to pass five checks:
- The slot ID in the object’s metadata matches the slot.
- The object matches the slot’s content type and size.
- The slot has not expired, subject to an optional late tolerance.
- A duplicate commit for the same slot is idempotent, not contradictory.
- An optional
commitPolicyhook gets the final say.
The commitPolicy hook is the seam for the application. OLOS knows nothing about users, quotas, or moderation, so anything that depends on application state goes through it:
TypeScript
const policy = options.commitPolicy?.({ commitId, object, slot, state });
// → { status: "allowed" } | { status: "rejected", error }The hook can only allow or reject. It can’t touch coordinator state, and it runs on every path that can publish an object: commits, provider events, reconciliation, and recovery. Recovery can’t slip in something the live path would reject.
The cursor and the live window
A committed object is folded into the CommittedWindow, the ordered set of objects currently live, and the coordinator advances the cursor.
The cursor is the live edge. Everything a viewer sees is rendered from it, and it has one rule the whole protocol leans on. It only ever moves forward.
The cursor & live window
TypeScript
const update = resolveCursorUpdate({ candidateCursor, currentCursor });
// → "advanced" | "idempotent" | "regression"A candidate cursor behind the current one is rejected as a regression. That rule is what lets several things race on the same session at once: a live publisher, a redelivered event, a recovery job, a retention sweep. The cursor is persisted with a compare-and-set on an etag, so two concurrent writers can’t both win.
The same rule allows parallel uploads. Commits don’t have to arrive in order, because the cursor only advances across the contiguous prefix of committed objects. An out-of-order commit is recorded but doesn’t move the live edge until the gap before it fills. A publisher can therefore upload a segment’s parts in parallel:
The publish loop
As the cursor moves on, older objects fall out of the live window and surface as retiredObjects, so the runtime can delete their bytes in the same step. Without that, the persisted state and every manifest render would grow with the age of the stream instead of staying proportional to the window.
Serving the stream
Reading is the easy half, because there is nothing to trust.
A viewer requests an HLS manifest, and the coordinator renders it from the cursor and the CommittedWindow. It never lists the bucket to find out what is live, and it never reads playlist text a publisher uploaded, because there is no such thing. The manifest is computed from trusted state every time. Committed bytes are served with nosniff, so a mislabelled object can’t be reinterpreted behind a cached URL.
For low-latency playback, OLOS uses LL-HLS with blocking playlist reloads. A player can request a future media sequence and part (_HLS_msn and _HLS_part), and the request blocks until the cursor reaches it or the wait times out. The reference configuration runs 0.5s parts, 2s segments, and a 3s blocking-reload wait.
Benchmarks
The reference deployment is in the OLOS repo:
- The
streamerexample is an RTMP/ffmpeg bridge. It takes OBS over RTMP, transmuxes to fragmented MP4, and publishes the chunks to OLOS as LL-HLS parts and segments. - The
apiexample is the coordinator: a Cloudflare Worker and aStreamCoordinatorDurable Object holding the live state, with R2 as the media store. - The
playerexample plays the stream in a browser.
End to end, that is OBS to bridge to OLOS to R2 to player. Deployed to Cloudflare, the player lagged the live source by roughly 4 to 4.5 seconds. I measured that by eye, which is why it is a range.
Most of that latency isn’t OLOS. It is the encoder’s buffer, the LL-HLS part and segment cadence, and the player’s own buffering, which every low-latency HLS system pays for. To find what the protocol itself adds, I benchmarked the commit-and-serve path on its own, with real H.264 video pushed through an in-process coordinator backed by in-memory storage. No S3, no network.
Over 1,000 parts on an M1 Pro with 100ms parts:
Latency breakdown (median, local benchmark)
| stage | p50 | p95 |
|---|---|---|
| encode fill | 121.7 ms | 134.4 ms |
| publish (commit) | 1.02 ms | 68.3 ms |
| wake (manifest) | 0.16 ms | 0.24 ms |
| fetch | 0.30 ms | 1.26 ms |
Almost all of it is encode fill. Committing the object and waking the blocking manifest reload add about 1.2 milliseconds at the median, combined. End to end, that is 126ms at the median, 202ms at p95, and 334ms at p99.
Surviving failure
A stream runs for hours, and things fail while it runs. Processes restart, publishers drop, and storage events arrive late or twice.
None of that corrupts the stream, because the coordinator’s live state is persisted rather than held in memory. The cursor, the committed window, and the open slots all live in the coordinator’s state store, behind the same compare-and-set the cursor uses. A coordinator that restarts mid-stream reloads that state on the next request and continues from the cursor it left at.
A publisher holds a short-lived lease, which is not a commit lock. A publisher that drops lets its lease expire, and a standby can see that through the health endpoint and take over. If two publishers commit different slots at once, neither is rejected for racing. Safety comes from the forward-only cursor and its compare-and-set.
The harder failure is a publisher that uploads an object and dies before committing it. Its bytes are in the bucket, on an issued slot, inert and in no manifest. Two things can still finish the job: a storage event for that key, or a recovery sweep over the session’s open slots. Both run the same observe-and-commit path a live publisher uses, re-reading the object and re-checking its size, type, and slot ID. A recovered commit can never be something the live path would have rejected. The slot’s expiry, plus any late tolerance, bounds how late this can happen. Because commits are idempotent, the sweep can run as often as needed.
One gap remains. The coordinator checks the observed size against the slot’s byte bounds, but it doesn’t inspect the contents. An object that satisfies the bounds without being a valid part would still commit. A tight minBytes rules out anything too small to be real. Closing the gap fully would need a content check that the protocol doesn’t do.
Why a protocol?
When I started, this was “can I live stream from R2?”. A handler, a bucket, some segments. It became a protocol because the boundary between uploaded objects and official live state has nothing to do with R2, S3, or even video.
OLOS is not an encoder, a muxer, or a WebRTC competitor. It defines that boundary, in layers that each answer one question:
- Core: what makes an uploaded object a committed part of the stream. Slots, observations, commits, cursors, the committed window.
- LL-HLS binding: how the committed window becomes a playable low-latency manifest.
- S3-compatible binding: the minimum a storage backend has to provide. Exact-key uploads, conditional create, consistent reads, optional events.
- Direct-public deployment: the configuration where committed bytes are served straight from the media origin.
Keeping the core media-agnostic is what makes the rest swappable. The same store contract works with S3, R2, GCS, or a Durable Object. The conformance suite pins down what “an OLOS coordinator” means, so two implementations can agree on the boundary instead of each inventing their own.
The committed object the coordinator tracks is deliberately plain:
TypeScript
interface CommittedObject {
commitId: OlosId;
contentType?: string;
deliveryUrl: string;
duration?: number;
etag?: string;
objectKey: string;
slotId: OlosId;
}There is nothing about codecs, frames, or pixels in there. Parts, byteranges, and keyframe-aligned segments live one layer up, in the LL-HLS binding. The core, an ordered sequence of committed objects with a moving live edge, fits anything that arrives as immutable chunks over time:
- A live podcast with no video track.
- Subtitle segments committed alongside the media they describe.
- A drone’s position, or a car’s lap data.
- A stream of price ticks, where order is everything.
Final thoughts
I started this to see whether R2 could carry low-latency live video. It can, and the coordinator’s share of the latency is about a millisecond per object.
What came out of it is a way to keep the bucket from defining the stream. Object storage holds the bytes. The coordinator decides what is live.
The protocol and reference implementation are on GitHub.