React · Real-time · Live production
Live broadcast graphics with React: what changes when millions are watching
Five rules from two years of building and running on-air graphics for esports world championships, including the Sports Emmy-winning The International 10.
For two years at PGL I built and ran the live graphics for esports world championships: The International in 2021 and 2022, PGL Majors in Stockholm, Antwerp and Arlington, the ALGS 2022 Championship, Brawl Stars World Finals and more. The overlays, brackets, statistics and schedules you see on the stream were React applications, and I also built the multi-view feature that let viewers watch several games at once. The biggest broadcasts peaked at over 2.7 million concurrent viewers, and the broadcast of The International 10 won the 2022 Sports Emmy for Outstanding Esports Championship Coverage.
I was also the person running those graphics on air, following the show's rundown in real time. That changes how you write front-end code. There is no "we'll fix it in the next deploy". If something flickers, millions of people see it flicker.
Graphics are web pages
Modern broadcast tools like vMix and OBS can load a web page as a layer, a "browser source", with a transparent background, composited over the game feed. So a scoreboard is a React app rendered at exactly 1920×1080, with no scrolling, no layout changes and nothing interactive. Everything that works for a normal website still applies; a few things matter much more.
Rule 1: nothing moves unless you meant it to
A layout shift on a website is annoying. On a broadcast it's a visible glitch. So every graphic is built on a fixed canvas, with fixed-width slots for anything that changes: player names, scores, timers.
- Fonts and images load before the graphic goes on air. Never let the first frame show a fallback font or an empty logo slot.
- Numbers use tabular figures (
font-variant-numeric: tabular-nums), so a timer going from 1:11 to 1:10 doesn't make the text jump. - Animations use only
transformandopacity, the properties the browser can animate without re-laying out the page, so they stay smooth at 60 fps.
// Only report "ready" to the operator once everything the graphic needs is decoded
export async function preload(images: string[], fonts: string[]) {
await Promise.all([
...images.map((src) => {
const img = new Image();
img.src = src;
return img.decode();
}),
...fonts.map((font) => document.fonts.load(font)),
]);
}Rule 2: the operator always wins
Data arrives automatically: live game state during a match, results and brackets from the production team. But what is on air is a human decision, made by the operator following the rundown. On those shows, that was me. The graphics should make that decision easy and safe. A pattern that works well: every graphic listens for two kinds of messages, data updates that change what's inside it, and show and hide commands that take it on and off air, whether they come from the vision mixer or from your own control panel.
type Message =
| { type: "data"; graphic: string; payload: unknown }
| { type: "show" | "hide"; graphic: string };
export function useGraphic<T>(graphic: string, socket: WebSocket) {
const [data, setData] = useState<T | null>(null);
const [onAir, setOnAir] = useState(false);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const message = JSON.parse(event.data) as Message;
if (message.graphic !== graphic) return;
if (message.type === "data") setData(message.payload as T);
else setOnAir(message.type === "show");
};
socket.addEventListener("message", onMessage);
return () => socket.removeEventListener("message", onMessage);
}, [graphic, socket]);
return { data, onAir };
}Rule 3: stale data beats no data
Feeds drop. When they do, the worst thing a graphic can do is go blank. Keep the current state of everything on the server (Redis is a natural fit), so a graphic that reconnects, or a browser source that gets reloaded mid-show, gets the full picture back instantly instead of waiting for the next event. In the meantime it keeps showing the last good values.
// Every update is stored as the latest state and broadcast to everyone listening
async function publish(graphic: string, payload: unknown) {
await redis.set(`state:${graphic}`, JSON.stringify(payload));
await redis.publish("graphics", JSON.stringify({ type: "data", graphic, payload }));
}
// A new or reconnecting client gets the current state first
wss.on("connection", async (socket) => {
for (const key of await redis.keys("state:*")) {
const payload = JSON.parse((await redis.get(key))!);
socket.send(JSON.stringify({ type: "data", graphic: key.slice(6), payload }));
}
});Rule 4: the vision mixer has a budget
The graphics don't run alone. They share a machine with the vision mixer, which is also decoding several video feeds, compositing them and encoding the stream. At one point we had graphics with assets far heavier than they needed to be, and the result was lag in the live output. Optimizing those assets removed the lag and unlocked features we had held back because of performance limits.
The lesson carries straight over to the web: measure on the real target. A graphic that runs smoothly in a desktop browser can still be too heavy for a machine that is busy doing five other things.
Rule 5: make the next event cheaper than the last
Every championship needs its own graphics package: new branding, new layouts, sometimes a new game. Early on, a new package took two or three days. I turned what we kept rebuilding into a shared boilerplate, with automation around it. After that, a new package took less than a day, and every project from then on started from it.
The same thinking applied to setup. Preparing a broadcast machine meant installing the right Node.js version, Redis, the graphics package and its configuration. I wrapped that into a one-click installer built with Electron, so a production machine could be ready without anyone having to remember a checklist on show day.
What I took from it
- Production is a rehearsal you only get once. Rehearse with real data, real machines and the real rundown before the show.
- Manual override is a feature. Automation does the work; a person makes the call.
- Performance is part of correctness. A late frame on air is a bug, not a metric.
I still use all of this on the web. It's why I care about layout shifts, preloading and real-device testing in the products I build today.