P2P feed buffer & window tuning
This page explains how the live feed moves over the peer-to-peer network, the
two buffer modes (disk vs ram), and how to size the HLS segment window
for a good streaming experience. If you are chasing slow time-to-play, start
here.
The transport is Hypercore, not WebRTC
A common misconception: Aliran is not "HLS P2P over WebRTC." Two independent layers are involved:
- Media format = HLS. ffmpeg produces plain MPEG-TS segments (
seg0.ts,seg1.ts, and so on) plus anindex.m3u8playlist, purely so any standard HLS player can decode the bytes. - P2P transport = Hypercore / Hyperswarm (the Holepunch/Pear stack). The
segments are mirrored into an encrypted Hyperdrive and replicated over
Hyperswarm's DHT (UDP hole-punching). There is no
RTCDataChannelanywhere — tuning WebRTC knobs would be chasing a ghost.
The segments are the entire payload shared peer-to-peer. The rolling-buffer work did not remove segments — it stopped the feed hoarding old ones (see below).
Buffer modes: disk (default) vs ram
The live feed is a rolling window, not an archive. The playlist defines
which segments exist, and everything that rotates out is deleted from the
drive and its blob storage is reclaimed (hypercore clear() frees each
segment's blocks as it rotates out). A channel that streams for days
occupies O(window) storage in either mode. Set the mode with the
FEED_BUFFER env var or a per-channel buffer field.
disk (default) |
ram |
|
|---|---|---|
Feed identity (feedKey) |
Stable across restarts | Fresh every start |
| DHT discovery topic | Stable → returning viewers rejoin a warm topic | New every restart → cold discovery each time |
| Viewer replica | Resumable from on-disk cache | Always cold-synced |
| Broadcaster storage | Window-bounded (tens of MB of segment data)† | Byte-flat (memory only) |
| Best for | Normal operation — fastest time-to-play, healthiest P2P | Hosts that must keep the disk byte-flat |
In both modes the encryption key persists (feed.key in the
channel's store dir), so user grants seal it and restarts never invalidate
access. Viewers follow a feedKey change (RAM restarts) automatically — the
player SDK resolves the CURRENT feedKey from the replicated catalog at play
time, so no re-login is needed. A re-keyed stream (new encryption key)
still needs a fresh login — that is a deliberate access-control boundary.
† Disk growth: O(window), and how it can fail
Symptom: a
disk-buffer channel's store dir (DATA_DIR/channels/<id>/) grows to gigabytes over a long run — far past the ~tens-of-MB window — and can fill the host disk (this once filled a VPS to 100 % and killed the channels).Cause: the feed is a hypercore (append-only).
clear()frees the blocks of rotated segments, but reclaim used to sweep only below the single lowest offset still referenced by a live entry. If one entry gets stuck at a low offset — an orphaned segment stranded when the ffmpeg watchdog respawns (a fresh ffmpeg resets itsseg%dcounter to 0 and abandons the previous run's high-numbered.tsfiles, which stay on disk and never rotate out) — that entry pins the watermark, and every segment above it accumulates forever. Disk mode persists the core across restarts, so the pin (and the leak) survived reboots.Fix (shipped): the mirror now frees each blob as its segment rotates out (and when a re-put supersedes it), so reclaim no longer depends on a global watermark a stuck entry could pin. On (re)start it also reconciles the reopened core against the fresh output dir, dropping any orphaned entries a prior run stranded — expect a large first reclaim on the next start after upgrading. The orphan-pin scenario is covered by
test:retention.
Two different bounds: segment data (automatic) vs metadata (rotate to bound)
The rolling reclaim above bounds the segment (blob) data — the acute
term, always O(window) in both buffer modes. It does not bound a
hypercore's merkle tree and metadata: those are append-only, and
clear() never frees them. They grow slowly with total runtime (order
~1–2 MB/h per channel at a 2 s window — roughly 100–250 MB/day across
a handful of channels), independent of window size. This is a slow
disk creep, not a crash risk — but on a multi-week 24/7 channel it
accumulates.
Two contributors, each with its own fix:
- Retired feed generations. Every time a feed rotates identity — a
source change bumps
feedGen, or a periodic rotation (below) — the previous generation's cores are orphaned on disk, tree and all. The broadcaster now GCs these automatically: at every start (a reopened disk store sheds prior generations) and after each rotation, it deletes the retired generation's whole core directory. Nothing to configure. - A single long-lived feed's own tree. A feed that never rotates
grows its tree for its whole lifetime. A broadcaster restart does
not reset this (disk mode reopens the same core) — only a feed
rotation (fresh
feedGen→ fresh core) or a re-key starts the metadata over. Bound it with periodic feed rotation (FEED_ROTATE_HOURS/FEED_ROTATE_TREE_MB, off by default) or a manualPOST /api/channels/<id>/rotate. Watching viewers follow the newfeedKeylive over the catalog (next section) — the only cost is a one-time cold DHT topic for viewers returning across a rotation, so keep rotation infrequent (weekly, for example) to preserve disk mode's warm-topic benefit.
Where the creep lands depends on the buffer mode. In disk mode it
is disk bytes (the OS page cache absorbs the reads, so node RSS stays
flat). In ram mode those same append-only files are process memory,
so node RSS itself creeps (order ~10 MB/h for a 6-channel box at a 4 s
window), and only a restart (which IS a feed rotation in ram mode)
resets it. On a small-RAM host running ram buffers 24/7, plan a
periodic restart/rotation — or use disk mode, which keeps RSS flat for
free.
Fixed: unbounded per-feed metadata caches (the fast RSS leak)
Before the fix, each feed's Hyperbee kept two internal caches (decoded
btree nodes and keys) keyed by the ever-growing append seq — about
1.5 KB of heap retained per metadata append, forever (~24 MB/h at 6
channels, in either buffer mode; this is what re-filled a 1 GB box
within hours). All channels' cores now share one bounded global cache
budget (FEED_CACHE_MAX, default 8192 entries ≈ 10–15 MB ceiling). A
6-channel soak verified this: heap stayed flat after the budget filled.
tools/mem-soak.mjs reproduces and measures both this and the slow
creep above.
Tuning feed rotation: disk vs. warm-topic
Periodic rotation is the one lever that bounds a long-lived feed's metadata tree. It is a single trade-off: rotate more often for tighter disk, or less often to preserve disk mode's warm-topic benefit for returning viewers. It is off by default — enable it only on long-running (multi-week) channels that actually need the bound.
Pick a trigger (both 0 = off; if both are set, whichever fires
first):
-
FEED_ROTATE_TREE_MB— rotate a channel once its merkle tree crosses N MB. This is the direct disk cap: each channel's metadata is bounded to ~N MB, so the store's steady-state ceiling is aboutN × channelsplus the (small) segment windows. Rotation cadence follows the tree's growth rate, which is roughly 1–2 MB/h per channel at a 2 s window (higher-bitrate, churnier channels grow faster and hit the cap first):Cap Busy channel (~2–4 MB/h) Quiet channel (~1 MB/h) 150every ~2.5–3.5 days ~1 week 250every ~4–6 days ~1.5–2 weeks 400every ~1–1.5 weeks ~2–3 weeks Measure your own rate from the
tree_allocslope of a store-size sampler (order ~200 kB/min total for 6 channels was observed in production), and size the cap to the rotation cadence you can accept. -
FEED_ROTATE_HOURS— rotate every N hours regardless of size (168 = weekly, for example). This is simpler to reason about ("every channel cold-topics at most weekly"), but it does not cap disk directly — a high-bitrate channel can still grow a large tree between rotations. Prefer the size cap when disk is the constraint; prefer time when a predictable warm-topic cadence is. -
FEED_ROTATE_GRACE_MS(default30000) — how long the retired generation keeps serving and stays announced after a rotation, so in-flight viewers finish following the catalog before its cores are purged. This rarely needs changing.
What a rotation costs viewers. A channel keeps streaming through a
rotation — the new generation mirrors the same live window before the swap,
and both serve during the grace window. An actively-watching viewer
auto-follows the new feedKey over the catalog with no re-login and no
re-zap, at most a brief re-buffer as the host reloads its player. The real
cost lands on viewers returning across a rotation: a new generation
means a new DHT topic, so their next join is a one-time cold discovery
(~30–90 s) instead of the ~10 s warm rejoin. New and first-time viewers are
unaffected. This is why infrequent rotation (a larger size cap, or
weekly) is the recommended default for lean-back audiences — it keeps the
warm-topic experience for the overwhelming majority of returns.
Rule of thumb. Warm-topic-first (most deployments): use a size cap of
250–400 MB, or FEED_ROTATE_HOURS=168. Disk-first (small host, many
channels): use a smaller size cap. Rotation only ever fires on a
healthy, advancing channel, and cadences naturally stagger across
channels — there is no synchronized mass-rotation event. Retired
generations (and any left by source changes) are purged automatically;
nothing needs manual cleanup.
One-off / manual: POST /api/channels/<id>/rotate rotates a single
running disk channel immediately — handy for reclaiming a specific channel,
or for proving the mechanism without waiting for a threshold.
Unclean shutdown: a corrupt store self-heals
A disk feed's cores are append-only files that must be closed cleanly.
If the broadcaster is killed mid-write — SIGKILL, OOM, power loss, or a
docker stop that outruns its grace period while many channels are
closing — a core's oplog/tree can be truncated, and the feed then
refuses to reopen (EPARTIALREAD / OPLOG_CORRUPT). That would
otherwise strand the channel on every boot. The broadcaster
self-heals: on start it detects the corruption and rotates the
channel to a fresh generation (new cores + feedKey; the
encryption key and grants are untouched, so viewers just follow the new
key via the catalog), then GCs the corrupt generation. It logs this
(self-healing to a fresh generation), and any boot-resume failure is
now logged too — a channel never fails silently. As defence-in-depth on
the prevention side, the compose file sets stop_grace_period: 60s so
a clean shutdown has time to finish. RAM-buffer feeds are immune —
their store is fresh every start.
Why ram is slower to join, not faster
Symptom: time-to-play from a fresh client store is ~40–55 s (vs ~10 s expected), and it is slow again after every broadcaster restart.
Cause: the RAM buffer does not just hold data in memory — because a RAM-backed Corestore generates a fresh primary key on every start, the
feedKeyand itsdiscoveryKeyare brand new after each restart. So every viewer must: 1. do a cold Hyperswarm DHT lookup on a topic that was only just announced (this is the dominant cost — tens of seconds), and 2. cold-sync the whole current window from the single broadcaster seed, because no prior replica of this keypair exists anywhere.A ramdisk does not fix this — going RAM-backed is precisely what turns "cold DHT once" into "cold DHT after every restart."
Fix: use
FEED_BUFFER=disk(the default). A disk Corestore persists its primary key, so thefeedKey/topic stay stable across restarts: returning viewers rejoin a warm topic and resume their on-disk replica. Storage still stays window-bounded via the same rolling reclaim, so you keep the disk-safety win without the cold-start tax.
Switching an existing channel
- Globally: set
FEED_BUFFER=disk(orram) in the broadcaster env and restart. - Per channel (control API):
PATCH /api/channels/<id> { "buffer": "disk" }, then stop/start the channel.
Switching from ram to disk mints one new stable feedKey on the next
start (a single cold discovery), after which the identity is fixed. The
encryption key is untouched, so grants stay valid.
Running many channels? IOPS, not space, is the wall
Buffer mode above is about one feed's storage. When you run many
channels the constant per-segment write churn becomes
disk-IOPS-bound — the reason traditional streaming uses a ramdisk.
Turn on the scale profile (HLS_WORK_DIR on tmpfs +
FEED_BUFFER=ram) and see
Scaling & capacity planning for per-channel RAM/CPU/IOPS
numbers and a hardware sizing table.
Sizing the segment window (HLS_TIME / HLS_LIST_SIZE)
The window is HLS_LIST_SIZE segments of HLS_TIME seconds each. Defaults
are 2 s × 8 ≈ 16 s. These knobs affect live-edge latency,
first-frame buffering, compression, and per-block P2P overhead — they do
not touch DHT discovery latency (that is the buffer-mode lever above).
HLS_TIMEis the main startup lever. The host player prebuffers roughly3 × HLS_TIMEbefore playback begins, all pulled cold from the seed. At ~3 Mbps that is ~6 s of media at 2 s segments vs ~12 s at 4 s.- Shorter (2 s): faster first frame, lower live latency.
- Longer (4–6 s): better compression (fewer forced keyframes), fewer files, less metadata churn — but a slower first frame. Don't go below 2 s (keyframe/compression cost climbs) or above ~6 s (first-frame latency hurts).
HLS_LIST_SIZEis the P2P-shareability / rebuffer-cushion lever (window depth =HLS_LIST_SIZE × HLS_TIME). It barely moves startup — the player starts ~3 segments back from the live edge regardless.- Small swarm (a few viewers):
8(≈16 s) works. - Large swarm (many concurrent viewers):
12–16— a deeper overlap means a joiner can pull from more peers and ride out a peer dropping. - Live-offset margin (why
12is now the standard): the viewer apps sit ~10 s behind the live edge and replicate the whole window on-device (churn headroom — see viewer bandwidth). A 24 s window (12 × 2 s) gives that offset comfortable margin; the 16 s default leaves only ~6 s of slack past the offset. Treat8as the hard floor: the env check permits values as low as2, but a window shorter than the ~10 s offset breaks playback. - Client blip-recovery margin (same lever): the window is also how
long a viewer's network may hiccup before the live edge slides past
their player and the picture freezes. The app self-heals that (the
<AliranVideo>stall resync — see playback), but a deeper window prevents the freeze instead of recovering from it: at8×2 sany ~16 s Wi-Fi blip freezes mobile viewers;12–16(24–32 s) rides most of them out. The cost is a proportionally larger per-channel window store — small since the reclaim fix.
Recommended starting point: HLS_TIME=2, HLS_LIST_SIZE=12,
FEED_BUFFER=disk. Go to 16 for large swarms or very flaky viewer
networks (mobile/Wi-Fi) — the 2026-07-16 S22 freeze happened at 8×2 s.
Note — keyframe alignment with
copy: the encoder must emit a keyframe everyHLS_TIMEseconds (OBS "keyframe interval", for example) or segments won't cut cleanly. For transcoding encoders, Aliran forces this automatically.
Time-to-play expectations (healthy system)
| Scenario | Expected |
|---|---|
| Fresh client store, first ever connect | 30–90 s (cold DHT + cold replica) |
Returning viewer, disk broadcaster (warm topic) |
~10 s |
Returning viewer after a ram broadcaster restart |
40–55 s (cold DHT again) |
| After the playlist appears | a few seconds while the live edge replicates (the server holds requests until the content lands — no 404 churn — and streams segment bytes as blocks arrive) |
1 peer in broadcaster status means the broadcaster only — each extra
viewer is an extra seeder. See also the latency notes in
Operating the panel & broadcaster.
Channel zapping (switching in a warm session)
Zapping is not the same as cold time-to-play. Once you are logged in, switching channels skips the panel connect and login entirely — the only cost is the target feed. Measured over the public DHT against a 2-channel deployment, warm session:
| Switch | What it is | Time to playable |
|---|---|---|
| first channel | cold first-play (already logged in) | ~2.5 s |
| → a new channel | cold first-zap | ~1.2 s |
| → back to a watched channel | warm re-zap | ~0.3–0.4 s |
Why the shape works this way:
- Each channel is its own P2P feed with its own DHT topic, so the first zap to a channel this session cold-joins that topic and replicates its window. It is fast (~1 s) because the broadcaster peer is already connected — it seeds every channel — so there is no fresh DHT bootstrap, just the new feed's first segments.
- The SDK caches opened feeds and reuses them, keeping their topics
replicating in the background, so zapping back to a recently-watched
channel is near-instant — the replica is already warm (
resolve()returns in ~1 ms; the ~0.3 s is just fetching the current playlist and live segment). Disk mode extends this across sessions: the feed identity is stable, so yesterday's replica resumes instead of cold-syncing.
Fixed: re-zap used to hang
Before the feed-reuse fix, switching back to a channel opened
earlier in the session wedged resolve() — the SDK opened a second
Hyperdrive over the same store namespace and ready() deadlocked
against the still-open first one. serveFeed now reuses the cached
feed, so flip-back is ~0.3 s. The test:sdk zap news → movies → news
regression covers it.
Pre-warm: make the first zap warm too
The SDK can pre-warm entitled feeds right after login — it opens each
channel's replica and joins its DHT topic in the background, so the cold
discovery and handshake are paid upfront, off the play path. Then even the
first play or zap to a channel is a cache hit, not a cold open. On-device
(release APK, phone), with pre-warm on, the first play of channel 1 and the
first zap to channel 2 both logged feed:ready with no feed:open —
instant — where before they were cold feed:open → feed:ready.
Enable it with the prewarm option (AliranPlayer / the RN binding's
start()):
false(SDK default) — off.true— warm all entitled feeds.- a positive integer — cap to that many, warming lowest curated order first (the channels a viewer is likeliest to reach). The app ships a bounded cap so a large lineup doesn't join hundreds of topics at once.
It is bandwidth-cheap: replication is sparse, so pre-warm warms the connection, not a full download — segments only transfer when a feed is actually served. Pre-warmed feeds share the same single-flight cache as played feeds, so a play that races the warm reuses the one open — never a second Hyperdrive on the same namespace.
How segment bytes reach the player (progressive serving)
The localhost media server (shared core sdk/serve.js, used by the SDK
engine, the Android worklet, and the desktop tools) is tuned for zap
latency:
- Block-progressive bodies: a segment response streams 64 KB hypercore
blocks to the player as they replicate. ExoPlayer starts parsing the
first block while the tail is still in flight (every segment starts on a
keyframe, so decode can begin from the first bytes).
test:serveproves first-byte-before-full-blob with a gated replication pipe. - Availability wait: a playlist or segment that hasn't replicated yet is held (bounded at 6 s) and served the moment it lands, instead of 404ing the player into its 2.5 s retry remount.
- Live-edge read-ahead: for the active live stream, each playlist request kicks off a parallel background replication of the whole live window, so replication overlaps the player's strictly sequential fetch pattern instead of being demand-paged segment by segment. On a metered network the engine narrows this to the newest 3 segments, to cap the per-zap data cost.
Zap prefetch: keep the neighbors' live edge warm (optional)
zapPrefetch (SDK option / RN start() option; off by default) goes
one step past pre-warm: while a channel plays, the SDK keeps the newest
segment of the next/previous channels in curated zap order replicated
locally (following each neighbor's current catalog feedKey), so a CH+/CH−
zap starts from warm bytes — typically shaving the target's first-segment
fetch off the switch entirely.
true— one neighbor each way, refreshed every 3 s ({ neighbors: 1, intervalMs: 3000 }).{ neighbors, intervalMs }— widen the warm ring or change the cadence.
The cost is standing bandwidth, which is why it's off by default: a live
feed rotates a new segment every HLS_TIME seconds, so keeping a
neighbor's newest segment warm downloads roughly that channel's full
bitrate for as long as you're watching — about 2× your playing channel's
bandwidth with neighbors: 1 (one ahead and one behind). Enable it for
lean-back TV profiles where zap feel beats data budgets; leave it off for
metered or mobile viewers. The test:sdk zap-prefetch section covers it —
a neighbor's newest segment must be fully local without ever being served
over HTTP.