How LTBR FM Fits a Live Radio Station Into 4 KB Messages
How London Tower Block Radio streams its live timeline to web, video and Android over one PushFlo channel, and the byte budget that keeps it under the cap.
LTBR FM (London Tower Block Radio) is a 24/7 internet station: roots, lovers rock, dub, soul and jazz funk, with DJs, station jingles and a real running order. It's also one of the busiest PushFlo channels I know of, and every screen that shows what's on air reads from it.
This post is the first in a short series about that integration. I'll lay out the whole picture first, then go deep on one piece: how the station fits a live radio timeline into a 4 KB message, and what happened before it did.
The big picture
Here's the pipeline, from the playout engine to the listener's phone:
Playout player (desktop app, Rust)
│ POST radio-state every ~5 s
▼
Live Radio Director (station backend, self-hosted)
│ enriches: programme, DJ, lyrics, weather, DJ speech
│ PushFloServer.publish("ltbr-radio", ...)
▼
PushFlo channel: ltbr-radio
│
├──▶ ltbr.fm website + installable PWA (Next.js on Vercel)
├──▶ Broadcast canvas (rendered into the YouTube live video)
└──▶ Android app (React Native)
The player knows what is playing and where the playhead is. Every five seconds it posts its state to the Live Radio Director (LRD), the station's backend. LRD adds what the player doesn't know (which show is on, who the DJ is, the lyrics, London's weather, the text of the next DJ link) and publishes the result to a single PushFlo channel, ltbr-radio.
Everything a listener sees subscribes to that one channel with a public pub_ key:
- The website and PWA draw a timeline strip with a moving playhead, now-playing cards, the programme and DJ block, a lyrics strip, a
REClamp for pre-recorded shows and a "weak signal" hint when messages stop. - The broadcast canvas is a web page that a headless browser renders and encodes into the station's YouTube live stream. DJ speech balloons, the weather backdrop and the up-next rail are all PushFlo messages that end up as pixels in a video.
- The Android app draws the same timeline natively and drops the socket when the app goes to the background.
Why pub/sub fits a radio station
The backend isn't reachable from the internet. It lives on a machine that accepts no inbound connections, which is a sensible thing for a radio playout system to do. So nothing can poll it, and it can't hold WebSockets open for thousands of listeners anyway.
What it can do is make outbound HTTPS requests. Publishing to PushFlo is one REST call, and from there the message goes to every subscriber: the website, the app and the video. The website itself is serverless on Vercel and has no PushFlo code on the server at all. It just subscribes in the browser.
Two event types, one channel
The channel carries two kinds of message:
| Event | When | What's in it |
|---|---|---|
timeline-snapshot | Every ~5 s | Playhead, tracks and markers around it, programme, weather, DJ speech text with timing |
now-playing | Only when the track changes | Title, artist, show, DJ, start time, lyrics |
The snapshot is the important one. It is a full description of "what the station looks like right now", so any client that connects mid-track has everything it needs after one message. No history replay, no catch-up logic.
That design choice is also what made the next part necessary.
Deep dive: fitting a radio station into 4 KB
Every PushFlo plan has a maximum message size: 4 KB, 16 KB, 64 KB or 256 KB depending on the plan (see the technical specs). LTBR's key sits on a 4 KB cap. For months that was plenty. Then the snapshot grew.
Incident one: the DJ got chatty
On 22 September, the text of DJ links joined the snapshot so the broadcast canvas could show speech balloons in sync with the audio. A typical snapshot went to about 4.0 KB on the wire, right on the line. Some ticks squeezed through. Others came back with:
403 PLAN_LIMIT_EXCEEDED
Message size (4.2KB) exceeds limit (4KB)
About half the publishes were refused. A refused snapshot isn't a smaller update, it's no update. The website kept extrapolating the playhead, but the broadcast canvas, which shows a test card when the feed goes quiet, started flickering to "signal lost" on a live YouTube stream.
There was an extra wrinkle. Today the TypeScript SDK reports any 403 as a generic AuthenticationError ("insufficient permissions"), so the first log lines looked like a key problem, not a size problem. The response body has the real reason. The lesson stands either way: measure before you publish rather than trying to interpret errors afterwards.
Incident two: the top of the hour
The first fix was to drop DJ text when the message got too big. It worked until the next night, when the base snapshot, without any speech, went past 4 KB just before 01:00 and again just before 04:00.
The top of the hour is the busiest few minutes on a radio timeline: filler jingles, the hour marker, a show opener and up to three DJ links all land in the same window. Every publish was refused for a minute or two at a time.
So "drop the optional field" wasn't enough. The station needed a full strategy for deciding what gives way, in what order, and what never does.
Rule one: count bytes, not objects
The budget is measured exactly as PushFlo measures it, in UTF-8 bytes of the JSON that goes over the wire:
export function byteSize(value: unknown): number {
return Buffer.byteLength(JSON.stringify(value), "utf8");
}
export function messageBudgetBytes(): number {
const n = Number(process.env.PUSHFLO_MAX_MESSAGE_BYTES);
const limit = Number.isFinite(n) && n >= 1024 ? n : 4096;
return limit - 96; // publish envelope {"content":...,"eventType":...} + slack
}
Two details matter here. First, string.length is not a byte count. Artist names with accents and lyrics with curly quotes take more than one byte per character. Second, PushFlo wraps your payload in an envelope with the event type, so the budget is the cap minus a little headroom. The cap itself comes from an environment variable, so moving to a bigger plan is a config change, not a code change.
Rule two: an explicit priority ladder
When a snapshot is over budget, content gives way in a fixed order. The order is based on one question: what is a listener least likely to miss?
- Text of DJ links that already finished. Nobody is reading them any more.
- Dashboard-only telemetry. Per-track crossfade settings, ducking windows, the stream metadata echo. The station's admin dashboard uses them, but it gets the full feed from the backend directly. No public client reads them.
- Tracks and markers far from the playhead. The website's timeline strip shows about 4.8 minutes. Items are dropped farthest-first outside a ±8 minute window, and only if that isn't enough, ±5 minutes, then ±3.
- Upcoming DJ text, then the on-air DJ text. The last one standing is truncated at a sentence boundary before it's given up, because a balloon with the first two sentences beats no balloon.
For now-playing the ladder is one step: drop lyrics. The website has its own lyrics endpoint to fall back on.
Here's the heart of steps 1 and 2:
export function fitSnapshotToBudget(snapshot, budget = messageBudgetBytes()) {
let bytes = byteSize(snapshot);
if (bytes <= budget) return { snapshot, bytes, overBudget: false };
// 1. Finished rolls' text - nobody is reading it any more.
dropSpeech(finishedMarkers, /* truncateLast */ false);
// 2. Dashboard-only telemetry the website never reads.
if (bytes > budget) {
tracks = tracks.map(({ fadeInMs, fadeOutMs, trimDb, segueRule, ...rest }) => rest);
markers = markers.map(({ duckStartMs, duckEndMs, ...rest }) => rest);
icy = undefined;
bytes = byteSize(fitted());
}
// 3. Far items, farthest first, in shrinking keep windows (8, 5, 3 min)
// 4. Upcoming then on-air speech, truncating the last survivor
...
}
Each step re-measures and stops the moment the message fits. On a quiet afternoon nothing is touched. At the top of the hour the station might lose the far end of the timeline and a finished DJ link, and nobody notices.
Rule three: rank by time, not by type
"Drop speech first" isn't precise enough, because a DJ link that's on air right now matters more than anything else in the message, while one that ended four minutes ago matters least. The ranking is a single number based on where each marker sits relative to the playhead:
function speechPriority(startMs: number, durationMs: number, playheadMs: number) {
const endMs = startMs + (durationMs || 15_000);
if (startMs <= playheadMs && playheadMs < endMs) return 0; // on air
if (startMs > playheadMs) return 1_000_000 + (startMs - playheadMs); // upcoming, soonest first
return 2_000_000 + (playheadMs - endMs); // finished, most recent first
}
Sort descending, and the queue starts with the oldest finished link and ends with the one being spoken right now. Same idea for tracks: distance from the playhead decides, and the current track can never be dropped because it's always at distance zero.
Rule four: truncate like a human would
When the on-air DJ text is all that's left to cut, it's shortened rather than dropped, but only at a natural break:
function truncateSpeech(text: string, maxChars: number): string {
const head = text.slice(0, maxChars);
const sentenceEnd = Math.max(
head.lastIndexOf(". "), head.lastIndexOf("! "), head.lastIndexOf("? ")
);
if (sentenceEnd >= maxChars * 0.5) return head.slice(0, sentenceEnd + 1);
const space = head.lastIndexOf(" ");
return (space > 0 ? head.slice(0, space) : head) + "…";
}
The speech carries per-sentence timing so balloons appear in sync with the audio. Truncating the text also filters the timing entries past the cut, so the canvas never tries to highlight a sentence that isn't there. And below 200 characters the text is dropped instead, because a stub is worse than nothing.
Rule five: when it still doesn't fit, send it anyway and say so
If the ladder runs out and the message is still over budget, it's published anyway. PushFlo will refuse it, but the alternative, silently publishing nothing, hides the problem. The fitter returns what it did:
interface SnapshotFit {
snapshot: EnrichedTimelineSnapshot;
bytes: number;
droppedSpeech: number; // speech texts removed
truncatedSpeech: boolean; // nearest speech shortened
droppedItems: number; // far tracks/markers removed
overBudget: boolean; // still too big after everything optional went
}
The publisher logs one line per minute per station when anything was trimmed, not one every five seconds. When overBudget shows up in the logs, it's a clear signal: the base feed has outgrown the cap and it's time to rethink the payload or move to a bigger plan.
The other half: clients that expect less
Trimming only works if the subscribers are fine with missing content. On LTBR they are, by design:
- Optional really means optional. No lyrics in
now-playing? The website fetches them from its own route. No speech text? No balloon. The UI never assumes a field is present because it was present last time. - A missed tick is not an outage. Between snapshots the website moves the playhead forward from
playheadMs + (now - capturedAtWall). After 12 seconds of silence it shows a quiet "weak signal" hint. Only after 30 seconds does it reset to the "tuning in" state. That 30 used to be 10, which meant every backend restart wiped the visuals for every listener while the audio played on. - Every snapshot is complete. Because each message describes the whole visible state, losing one costs five seconds of freshness, not correctness.
Why not just upgrade?
The Starter plan's 16 KB cap would have made both incidents disappear overnight, and for some apps that's exactly the right call. I've written before about not paying for headroom you don't use, but the more interesting reason here is technical.
A bigger cap moves the cliff; it doesn't remove it. The payload has already grown more than once (weather, DJ speech) and will grow again. With a budget fitter in place, a growing payload costs some far-off timeline items for a few minutes a night instead of a flickering live video. And when the station does move up a plan, it's one environment variable.
A checklist for your own snapshots
If you publish state snapshots over PushFlo (or anything with a message cap), this is what I'd take from LTBR:
- Know your cap and put it in config, not code. The limits by plan are in the specs.
- Measure UTF-8 bytes of the serialized JSON, minus envelope headroom.
- Write the priority ladder down before you need it: what's optional, what's far away, what's never dropped.
- Rank by relevance to "now", not by field type.
- Degrade gracefully: truncate at sentence boundaries, drop stubs.
- Log trims at a low rate and alert on "still over budget".
- Make clients tolerant: optional fields stay optional, and staleness timers are longer than your longest planned restart.
Coming next in this series
The LTBR integration has a few more parts worth a post each:
- PushFlo data as video. How the broadcast canvas turns channel messages into a YouTube live stream, and reports the age of its last message back to the player so a stale feed shows up as "degraded" instead of "crashed".
- Background tabs and phones. Why browsers suspend sockets without firing
close, the watchdog that brings them back, and why the Android app disconnects on purpose. - One subscription, many components. Sharing a single channel subscription across a React tree, and handling two messages that arrive in the same render batch.
In the meantime, go and listen to LTBR. Every moving part on that page is a PushFlo message.
Build your own live feed
One channel, one REST call to publish, every screen in sync. Grab free API keys and ship your first snapshot today.
Written by Marek
Indie developer building PushFlo - real-time messaging for serverless apps. Every post comes from running the thing in production. More about PushFlo →
Related Articles
Why Serverless Functions Can't Hold WebSocket Connections (And How to Fix It)
A technical deep-dive into why WebSockets don't fit serverless architecture, and the practical ways to add real-time features to your apps.
Introducing the Go SDK: End-to-End Encryption and Workers That Don't Fall Over
PushFlo's official Go SDK is built for workers and daemons, not browsers: an end-to-end encrypted envelope and connections that survive weeks of uptime.
We Raised the Free Tier - and Shipped Pro & Business Plans
The free tier now has 100 connections, 100 channels and double the publish rate, plus new Pro ($49) and Business ($149) plans. And what soft limits mean.
