Caching and CDN for OTT delivery

An appendix for operating large installations: how caching of Perfect Streamer OTT responses works and how to place a caching reverse proxy or a CDN in front of the node. Delivery modes, URL formats and authorization are described in the main section OTT and DVR.

The server produces responses of three categories that differ in content lifetime and in their suitability for caching by intermediate nodes (reverse proxy, CDN, client cache).

1. Caching model

1.1. Resources and HTTP headers

Resource

URL

Content-Type

Cache-Control

TS segment (HLS)

/h<sess>/<keyHex>.ts

video/mp2t

public, max-age=60, immutable

VTT segment (subtitles)

/h<sess>/sub/<pid>/<keyHex>.vtt

text/vtt; charset=utf-8

public, max-age=60, immutable

fMP4 segment (DASH / LL-HLS)

/h<sess>/init.mp4, /h<sess>/<key|N>.m4s

video/mp4

public, max-age=60, immutable

DASH MPD

/h<sess>/index.mpd

application/dash+xml; charset=utf-8

public, max-age=1

HLS master

/hls/<stream>/<login>/<pass>/index.m3u8

application/vnd.apple.mpegurl

public, max-age=1

HLS media

/h<sess>/index.m3u8, /h<sess>/sub/<pid>/index.m3u8

application/vnd.apple.mpegurl

public, max-age=1

302 Redirect

/dash/<stream>/<login>/<pass>/index.mpd

no-cache, no-store

Raw TS

/http/<stream>/<login>/<pass>

video/mp2t

not set; not cached

1.2. Segment properties

The hexadecimal segment identifier in the URL (<keyHex> in paths /h<sess>/<keyHex>.ts) is computed as a CRC64 of the segment start time and the stream ID, and is globally unique. A segment URL addresses immutable content — repeated requests for the same URL return an identical byte stream (as long as the segment stays within the sliding window).

The immutable directive suppresses conditional revalidation by the client (If-None-Match, If-Modified-Since). The value max-age=60 provides compatibility with a typical timeShiftBufferDepth=40s.

CMAF fMP4 segments (.m4s) and the shared init.mp4 for DASH / Low-Latency HLS are addressed in the same way and cached under the same model (immutable, max-age=60). LL-HLS partial segments (parts) are byte-range requests into the same .m4s, so they do not create a separate cache entry.

1.3. Manifest properties

max-age=1 bounds the staleness of cached content at one second. Combined with proxy_cache_lock on (nginx), bursts of manifest requests are coalesced into one request to the origin per second.

1.4. Content variability

With absPath=0 (the default value, no a URL parameter) the HLS media manifests and the DASH MPD contain no session identifier in the body. The manifest content is identical across sessions belonging to the same (stream, param) combination. This allows a reverse-proxy cache to reuse an entry across sessions when the cache key is normalized.

With absPath=1 (URL parameter a=1) the manifest body contains absolute URLs that include the scheme, the host and the session identifier. The content becomes session-specific, and cross-session cache reuse is not possible.

2. Client behaviour

Client

Manifest refresh URL

Effect on the number of sessions

HLS players using the media playlist

/h<sess>/index.m3u8

One session per playback session

DASH players using the root URL

/dash/<stream>/.../index.mpd (refresh loop)

Handled by session reuse (see 3.2)

Browser players (MSE)

/h<sess>/... via <Location> / session URL

One session per playback session

3. Special mechanisms

3.1. HTTP 302 Redirect for DASH

A request of the form /dash/<stream>/<login>/<pass>/index.mpd returns a 302 Found response with the header Location: /h<sess>/index.mpd. The response body is empty. Authorization and session allocation are performed while the redirect is processed.

Clients that support redirect caching address the session URL directly in subsequent requests. Clients that do not support it repeat the redirect request. The cost of reprocessing the redirect is limited to the authentication check and the session reuse operations.

3.2. Session reuse for DASH

When processing a /dash/.../index.mpd request from the same login to the same stream (with the same adaptive flag), the server finds an already existing DASH session and returns its identifier again. No new session is created and no slot of the concurrent-connection limit is consumed. Only sessions in the same playback mode are reused: a live session and an archive VOD session (parameters t/d/epg) are not merged.

Applies to DASH only. HLS requires no separate reuse mechanism: HLS clients refresh the media playlist via the session URL and do not create a new session on every refresh.

3.3. Segment reuse across sessions

The path /h<sess>/<keyHex>.ts does not depend on <sess> when <keyHex> is resolved to content: <keyHex> globally and unambiguously identifies a TS segment within the stream. Nginx with a normalized cache key (one that strips the /h<sess>/ prefix) serves all requests for the same <keyHex> from a single cache entry, regardless of which clients issued them.

Deduplication applies only to content-addressed names — segments with a 16-character hexadecimal identifier (.ts, .m4s with <keyHex>, .vtt). Numbered live-DASH segments (<number>.m4s), init.mp4 and manifests are unique only within their own session; their cache key must keep the /h<sess>/ prefix, otherwise the cache will serve the content of one stream to the viewers of another. For DASH, manifest deduplication between clients of the same login is provided by session reuse (see 3.2).

4. Request parameters

Parameter

Default value

Effect

a

0

1 — absolute URLs in manifests; 0 — relative

s

40

timeShiftBufferDepth in seconds

m

40

Minimum window length required to serve a manifest

v

6 for OTT modes, 3 for Peering / HLS

#EXT-X-VERSION in HLS (ignored by DASH); an explicit value in the URL overrides the default

h3

absent (off)

opt-in for HTTP/3 (QUIC): makes the server emit Alt-Svc in the response; sticky on the session URL. See HTTP/3 (QUIC)

Changing a parameter through the query string updates the values stored in the session the next time it is reopened. The archive playback parameters t, d and epg are described in Archive playback (VOD).

5. Load characteristics

The load on the origin scales with the number of distinct streams being watched concurrently. Increasing the number of clients watching the same stream does not increase the number of requests to the origin when a reverse-proxy cache and a normalized cache key are in place.

Scenario

Origin request rate (ref.)

1 client on stream X

MPD: 0.4 req/s, segment: 0.2 req/s

N clients on a single stream X (cache enabled)

MPD: 1 req/s, segment: 0.2 req/s

N DASH clients in refresh loop on a single stream

MPD: 1 req/s (with proxy_cache_lock)

N clients on N distinct streams

MPD: 0.4·N req/s, segment: 0.2·N req/s

Segments are deduplicated globally by their content-addressed names; manifest deduplication between clients is provided by session reuse (DASH) and by caching within the session.

6. Nginx as a caching reverse proxy

6.1. Basic configuration

proxy_cache_path /var/cache/nginx/pss_segments
    levels=1:2 keys_zone=pss_segments:100m
    max_size=20g inactive=30m use_temp_path=off;

proxy_cache_path /var/cache/nginx/pss_manifests
    levels=1:2 keys_zone=pss_manifests:10m
    max_size=256m inactive=5m use_temp_path=off;

upstream pss_backend {
    server 127.0.0.1:43972;
    keepalive 64;
}

map $uri $pss_cache_key {
    "~^/h[0-9a-f]{16}(?<tail>(/[0-9]+)?/[0-9a-f]{16}\.(ts|m4s)|/sub/[0-9]+/[0-9a-f]{16}\.vtt)$"  "stream:$tail";
    default  $uri;
}

server {
    listen 80;
    server_name stream.example.com;

    location ~* "^/h[0-9a-f]{16}(/[0-9]+|/sub/[0-9]+)?/([0-9a-f]+\.(ts|m4s|vtt)|init\.mp4)$" {
        proxy_cache               pss_segments;
        proxy_cache_key           $pss_cache_key;
        proxy_cache_valid         200 60s;
        proxy_cache_valid         404 403 0s;
        proxy_cache_lock          on;
        proxy_cache_use_stale     updating error timeout;
        proxy_cache_revalidate    on;
        proxy_ignore_headers      Vary;
        add_header                X-Cache-Status $upstream_cache_status;

        proxy_pass                http://pss_backend;
        proxy_http_version        1.1;
        proxy_set_header          Connection "";
        proxy_buffering           on;
    }

    location ~* "(^/h[0-9a-f]{16}(/[0-9]+|/sub/[0-9]+)?/index\.(m3u8|mpd)$|^/(hls|dash|llhls)/.*\.(m3u8|mpd)$)" {
        proxy_cache               pss_manifests;
        proxy_cache_key           $pss_cache_key;
        proxy_cache_valid         200 1s;
        proxy_cache_valid         404 403 0s;
        proxy_cache_lock          on;
        proxy_cache_lock_timeout  2s;
        proxy_cache_use_stale     updating;
        add_header                X-Cache-Status $upstream_cache_status;

        proxy_pass                http://pss_backend;
        proxy_http_version        1.1;
        proxy_set_header          Connection "";
    }

    location / {
        proxy_pass                http://pss_backend;
        proxy_http_version        1.1;
        proxy_set_header          Connection "";
        proxy_set_header          X-Forwarded-Proto $scheme;
        proxy_set_header          X-Forwarded-Host  $host;
        proxy_buffering           off;
        proxy_read_timeout        3600s;
    }
}

The origin port in the example is the default streaming HTTP server port (43972; HTTPS — 43982). Only content-addressed segment names are normalized (see 3.3); manifests, init.mp4 and numbered segments are cached under the full URI of their own session. The proxy_ignore_headers Vary directive in the segment location is justified: the server accompanies OTT responses with the header Vary: Origin, and without it the cache would be split into variants by the client Origin value (see 7).

6.2. Purpose of the directives

Directive

Purpose

proxy_cache_lock on

Serializes upstream requests on concurrent cache misses for the same key

proxy_cache_use_stale updating

Returns the stale copy to concurrent requests while the cache is being updated

proxy_cache_revalidate on

Uses If-Modified-Since on a cache miss when a stored copy is present

proxy_cache_valid 404 403 0s

Disables caching of authorization errors and 404

keepalive 64 in upstream

Maintains a pool of persistent connections to the origin

proxy_buffering on

For segments; enables response buffering in nginx

proxy_buffering off

For the / section; disables buffering (raw streaming)

6.3. Calculating max_size of the segment cache

Approximate value: bitrate × timeShiftBufferDepth × distinct_streams × 2

Example: 10 streams × 8 Mbps × 40s × 2 ≈ 800 MB. A 10x margin is recommended to account for bitrate variability.

6.4. TLS termination

The Perfect Streamer server accepts connections on the HTTP and HTTPS ports. When TLS is terminated on nginx, the upstream uses the HTTP port. Forwarding the X-Forwarded-Proto and X-Forwarded-Host headers is mandatory for correct construction of absolute URLs when absPath=1.

server {
    listen 443 ssl http2;
    server_name stream.example.com;

    ssl_certificate           /etc/letsencrypt/live/stream.example.com/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/stream.example.com/privkey.pem;
    ssl_protocols             TLSv1.2 TLSv1.3;
    ssl_session_cache         shared:SSL:10m;
    ssl_session_timeout       1d;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location ... {
        proxy_pass                http://pss_backend;
        proxy_set_header          X-Forwarded-Proto https;
        proxy_set_header          X-Forwarded-Host  $host;
        proxy_set_header          Host              $host;
        # + caching directives from 6.1
    }
}

server {
    listen 80;
    server_name stream.example.com;
    return 301 https://$host$request_uri;
}

When HTTPS is used between nginx and the origin, the directives proxy_ssl_verify and proxy_ssl_trusted_certificate apply. For loopback connections encryption is redundant.

6.5. Multi-host

When several server_name values are served from a single nginx process, $host is added to the cache key to isolate the content:

map $uri $pss_cache_key {
    "~^/h[0-9a-f]{16}(?<tail>(/[0-9]+)?/[0-9a-f]{16}\.(ts|m4s)|/sub/[0-9]+/[0-9a-f]{16}\.vtt)$"  "$host:stream:$tail";
    default  "$host:$uri";
}

The keys_zone size is calculated as 8000 keys/MB. For multi-host installations with thousands of streams, keys_zone=...:300m or higher is recommended.

7. Client-side caching

Cache-Control: immutable is honoured by modern browsers. The client cache returns the segment without a conditional request on repeated access (including a backward seek within the player buffer).

Service Workers may apply a cache-first strategy based on the Cache-Control contents. DASH players use MSE through SourceBuffer; a segment placed into the buffer remains available without a repeated HTTP request until it falls outside the sliding boundary.

For cross-origin requests the server returns Access-Control-Allow-Origin: * together with the header Vary: Origin, Access-Control-Request-Headers. A caching proxy that honours Vary splits the entries into variants by the client Origin value (browser players send Origin, console clients do not), which lowers the HIT rate. Since the ACAO is the universal “*”, it is safe to add proxy_ignore_headers Vary in the segment location (see 6.1).

8. Delivery through a CDN

Perfect Streamer is compatible with CDNs in pull-from-origin mode.

Origin shield. Placing one or several shield nodes between the CDN edge and the origin is recommended in order to reduce the origin request rate when clients are distributed globally.

Purge. Content-addressed segments require no purge. When the stream metadata changes (codec, resolution), the manifests are refreshed within max-age=1 without an explicit purge.

Cache warming. When a load increase on a particular stream is expected, warming the CDN from several geographic locations before the broadcast starts is acceptable.

Geographic distribution. Segments (max-age=60) are well suited to geographically distributed caching. Manifests (max-age=1) tolerate a delivery delay of up to one second — acceptable for live broadcasting without low latency.

9. Monitoring

9.1. X-Cache-Status

Add add_header X-Cache-Status $upstream_cache_status; to every location with caching. Values:

Value

Description

HIT

Response served from the cache

MISS

Not present in the cache, fetched from the origin and stored

EXPIRED

Expired, refreshed

UPDATING

A stale copy was served to a concurrent request during the update

STALE

use_stale returned an expired copy (the origin is unavailable)

REVALIDATED

The origin returned 304 Not Modified

BYPASS

proxy_cache_bypass was triggered

9.2. Access log format

log_format pss_cache '$remote_addr $status $request_method "$request" '
                     '$body_bytes_sent rt=$request_time ut=$upstream_response_time '
                     'cache=$upstream_cache_status key=$pss_cache_key';

server {
    access_log /var/log/nginx/pss.log pss_cache;
}

9.3. Metrics

The nginx-vts module exports per-zone metrics in Prometheus format:

GET /status/format/prometheus

Recommended alert thresholds:

Metric

Threshold

Possible cause

Segment HIT rate

< 90% over 5 minutes

Cache key normalization is broken; max_size is too small

Manifest MISS rate

> 50% over 1 minute

proxy_cache_lock is not serializing the requests

Upstream response time p95

> 500 ms over 1 minute

Origin overload

Cache zone fill

> 90% over 10 minutes

Approaching max_size, LRU eviction is imminent

Delivery quality on the node side is visible in the session metrics on the “Clients” screen (Clients).

10. Diagnostics

Symptom

Probable cause

Resolution

Low segment HIT rate

Vary: Origin splits the cache into variants; broken normalization in map

Add proxy_ignore_headers Vary to the segment location; check the regex in the map directive

404 on segments after they leave the window

A cached 404 for a segment that has fallen out of the sliding window

Add proxy_cache_valid 404 0s to the segment location

Playback start delay of 2–5 s

proxy_cache_lock_timeout exceeds the target latency

Reduce to 1–2 s; enable proxy_cache_use_stale updating

Manifest is not refreshed

proxy_ignore_headers Cache-Control is set and a proxy_cache_valid with a long TTL is in effect

Specify proxy_cache_valid 200 1s explicitly or stop ignoring the headers

Growth of TIME_WAIT on the upstream

No keepalive in the upstream block

Add keepalive 64, proxy_http_version 1.1, proxy_set_header Connection ""

403 on /dash/.../<segment>.m4s from console clients

The client resolves relative URLs against the pre-redirect URL

The server emits <BaseURL> with an absolute path; compatible in the current build

Stutter and frequent rebuffering on remote clients

Low effective TCP throughput caused by slow start and idle restart at high RTT (300 ms and above)

Tune the Linux network stack on the origin: see 10.1

10.1. Origin TCP tuning for high-RTT clients

The problem appears on clients with a high RTT to the origin (for example, 300 ms and above) when the stream bitrate is close to the channel capacity. The symptoms in the player are frequent rebuffering and dropouts; on the server the client looks normal and no errors are reported.

Cause:

  • TCP slow start. Every new TCP connection starts with a congestion window of about 14 KB and grows it over several RTTs. At an RTT of 300 ms, reaching the full window takes 2–3 seconds. During that time an HLS/DASH segment of 5 s duration (4–6 MB) is downloaded noticeably slower than real time.

  • TCP idle restart. Between segment requests, a client following the HLS pull model pauses for 4–5 s. By default, after such a pause the Linux kernel resets the connection congestion window back to the initial cwnd (the behaviour of net.ipv4.tcp_slow_start_after_idle=1). As a result, on the next GET the keep-alive connection starts transmitting from slow start again — even on an already warmed-up session.

An aggravating factor is that the default CUBIC congestion control copes poorly with long RTTs and packet loss on intermediate network segments.

The solution is two sysctl parameters on the origin:

# Keep congestion window across idle pauses inside keep-alive sessions.
sysctl -w net.ipv4.tcp_slow_start_after_idle=0

# Use BBR instead of CUBIC: better behaviour on long-RTT paths
# with mild packet loss; paces sending instead of bursting.
modprobe tcp_bbr
sysctl -w net.ipv4.tcp_congestion_control=bbr

To make the change permanent:

cat > /etc/sysctl.d/99-pss-net.conf <<EOF
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_congestion_control    = bbr
EOF
sysctl --system

The main effect comes from the first parameter (tcp_slow_start_after_idle=0) — it directly eliminates the repeated slow start between segment requests within a single keep-alive connection. The second one (BBR) provides additional robustness and applies to all new connections. The tuning does not require a restart of Perfect Streamer. These parameters do not apply to HTTP/3 (QUIC) clients: QUIC runs over UDP with its own congestion control.

11. Security

11.1. Session URL

A URL of the form /h<sess>/... acts as a session token — it requires no repeated authentication. Its lifetime is bounded by an inactivity timeout of 60 seconds; with no activity the session is deleted automatically.

Requirements:

  • HTTPS for all OTT paths (/hls/, /dash/, /h<sess>/) in production;

  • The session ID in the Location header of the 302 response is not cached (no-cache, no-store).

11.2. Rate limiting

limit_req_zone $binary_remote_addr zone=dash_top:10m  rate=5r/s;
limit_req_zone $binary_remote_addr zone=hls_top:10m   rate=5r/s;
limit_req_zone $binary_remote_addr zone=llhls_top:10m rate=5r/s;

server {
    location /dash/ {
        limit_req zone=dash_top burst=20 nodelay;
        proxy_pass http://pss_backend;
    }
    location /hls/ {
        limit_req zone=hls_top burst=20 nodelay;
        proxy_pass http://pss_backend;
    }
    location /llhls/ {
        limit_req zone=llhls_top burst=20 nodelay;
        proxy_pass http://pss_backend;
    }
}

The session URL (/h<sess>/) requires no rate limiting — processing is cheap and the responses are cached.

11.3. Caching of error responses

proxy_cache_valid 200 60s;
proxy_cache_valid 301 302 0s;
proxy_cache_valid 404 403 0s;
proxy_cache_valid any 1s;

Disables caching of redirects (a unique sess in Location) and of responses reporting authorization errors or a missing resource.

11.4. Restricting network access to the origin

The streaming HTTP server port (43972 by default; 43982 for HTTPS) must be closed to external traffic. Acceptable configurations:

  1. Bind Perfect Streamer to 127.0.0.1 (when nginx is local)

  2. Firewall rule:

iptables -A INPUT -p tcp -m multiport --dports 43972,43982 ! -s 10.0.0.0/8 -j DROP

12. Middleware integration

12.1. The prefix-login model

Perfect Streamer supports delegating user identification to a middleware system through the prefix-login mechanism — a built-in alternative to full integration with external billing (accounts verified by an external system are configured on the “Users / Logins” screen, the “External (billing)” tab, Users / Logins).

The prefix flag is enabled on a local login, after which the server accepts URLs with a login of the form <prefix><subscriber_id>:

/dash/test1/sub42/xxx/index.mpd
/hls/test1/sub43/xxx/index.m3u8

Here sub is the configured login prefix, and 42/43 are the middleware subscriber identifiers; the password is shared.

12.2. Per-subscriber statistics

In the client list each session carries both the configured login and the actual subscriber URL login (match-login), by which the middleware maps sessions to its own subscribers. The data is visible on the “Clients” screen (Clients).

12.3. Limitations of prefix-login

  • Shared password. All subscribers of a prefix pool use a single password value. Compromising the password grants access to any <prefix><string>.

  • Access granularity. The list of permitted streams applies to the entire prefix pool. Per-subscriber access control is provided by integration with external billing.

  • Password rotation. Changing the password disconnects all active subscribers. A gradual replacement requires the temporary use of two prefix logins.