Workstation4 / Blog / obs
obs overlay react performance

Making a quiz overlay behave inside OBS.

A streaming overlay looks like a web page, but it lives under rules a normal web page never has to obey. It has to land on the broadcast with a transparent background, it has to stay smooth inside a browser engine that was never tuned for a comfortable desktop tab, and its countdown has to tell the truth even when the renderer is busy. Quizo is a React overlay that streamers add to OBS as a browser source, and getting it to behave there taught me more than the rest of the project combined.

Transparent, not green

My first instinct was the classic trick: paint the background a flat color, then let OBS chroma-key it out. I shipped a version that did exactly that, rendered onto solid white, with a documented color-key filter (Key Color #FFFFFF, Similarity 400, Smoothness 80) and even a QA page with a test swatch so a streamer could calibrate the threshold once and trust it.

Then I learned I was solving a problem OBS does not actually have. An OBS browser source composites with a real alpha channel, so a page with a transparent body just works: whatever is behind it on the scene shows through, no filter required. I ripped the chroma-key path out entirely. The whole onboarding step disappeared, the QA test card became a short note, and the CSS got simpler:

.app-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100vw;
}

.overlay-area {
  flex: 1;
  background-color: transparent;
}

The lesson stuck with me: before reaching for a workaround, check whether the platform already gives you the thing for free. Native alpha removed a filter, a calibration step, and a class of "halo around the panel" bugs that color keying always invites.

Two URLs, one source of truth

The streamer needs controls to start rounds and watch state; the audience must never see those controls floating over the stream. Rather than render both into one page and hope a crop hides the controls, Quizo serves two separate routes off the same session: a control page the host keeps in a normal browser, and an overlay page that is the only thing dropped into OBS. The overlay is intentionally dumb. It renders whatever state it is handed and owns no game logic of its own.

  • The overlay page is presentation only; it never decides anything.
  • The control page drives the round and lives outside the broadcast entirely.
  • Both read the same server state, so there is nothing to keep manually in sync.

Keeping the overlay free of logic mattered later, because it meant I could be ruthless about its rendering cost without worrying I would break game behavior.

The embedded Chromium problem

The real surprise was performance. OBS ships its own embedded Chromium (the CEF runtime), and it is far less forgiving than a desktop tab. It renders alongside video encoding, scene compositing, and capture, so the overlay never gets the machine to itself. Animations that looked fine in Chrome started to feel rough once they ran inside OBS at 1080p30.

That pushed me to make every animation justify its cost, and the fixes were unglamorous but effective:

  • Animate transform and opacity, not layout. The countdown bar originally animated width from 100% to 0%, which forces layout and paint on every frame. I switched it to a scaleX transform with transform-origin: left, which the GPU composites cheaply.
  • Move repeating animations into CSS. The low-time timer pulse was a Framer Motion animate={{ scale: [1, 1.1, 1] }} loop running on the JS thread. I replaced it with a plain CSS @keyframes animation and a will-change: transform hint, so it never touches React's render loop.
  • Drop framer-motion where CSS suffices. The live vote bars went from animated motion divs to a single inline width with a CSS transition, cutting per-update JS overhead during the noisy moment when chat is voting.
  • Stabilize numeric widths. Every counter and timer got font-variant-numeric: tabular-nums so digits do not change width as they tick, which removes a subtle layout shift on every second.
  • Slow down background work. A status panel was recomputing relative timestamps every second; once per ten seconds is plenty and frees the thread for the things that actually move.

A countdown that tells the truth

The trickiest part was the clock. A trivia round lives and dies on its timer, so a counter that stutters or drifts is not cosmetic; it undermines the game. The naive design has the server tick a number down and push it to the client every second, but inside OBS that breaks in two ways. CEF can throttle a backgrounded or busy renderer, so React re-renders arrive late and the visible number skips, say, from 8 straight to 6. And second-resolution updates always look choppy next to a smooth bar.

A bar that looks like time is not the same as a bar that tells the truth about time.

The fix was to stop treating the countdown as a stream of values and start treating it as a deadline. The server computes a single absolute timestamp when a phase begins and includes it in the overlay state:

// server: when a timed phase starts
this.timeRemaining = durationSeconds;
this.stateStartTime = Date.now();
this.phaseEndsAt = Date.now() + durationSeconds * 1000;

// shared/types.ts
interface OverlayState {
  timeRemaining: number;   // server's coarse fallback
  phaseEndsAt?: number;    // ms since epoch: the real deadline
  // ...
}

The client then interpolates locally from that deadline, and it does so by writing straight to the DOM rather than going through React. A small hook runs a 100ms interval and sets textContent directly, which sidesteps the render pipeline CEF likes to throttle:

export function useCountdownRef(
  ref: RefObject,
  phaseEndsAt: number | undefined,
  serverTimeRemaining: number,
) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !phaseEndsAt) return;

    const remaining = () =>
      Math.max(0, Math.ceil((phaseEndsAt - Date.now()) / 1000));

    el.textContent = `${remaining()}s`;
    const id = setInterval(() => {
      el.textContent = `${remaining()}s`;
    }, 100);
    return () => clearInterval(id);
  }, [phaseEndsAt, serverTimeRemaining, ref]);
}

Because every tick is recomputed from the deadline, the number is self-correcting. If OBS drops frames or pauses the renderer for a moment, the next update snaps to where the clock genuinely should be instead of resuming from where it stalled. When the bar empties, the round really is over. There is one more subtle bit I did not expect: keeping a continuous Framer Motion progress bar on screen actually helps, because the ongoing compositor animation keeps CEF's renderer awake and less likely to throttle the page in the first place. The smooth bar and the honest number reinforce each other.

What I learned

The throughline is that an OBS overlay is a different target than a web page, and pretending otherwise tends to cost you late in the project. A few things I am carrying forward:

  • Use the platform's real capabilities first. OBS browser sources have native alpha, so chroma keying was a workaround I never needed.
  • Split presentation from control. A logic-free overlay page lets you optimize rendering hard without fear of breaking the game.
  • Budget animations for CEF, not Chrome. Prefer transform and opacity, push repeating animations into CSS, and stabilize numeric widths with tabular-nums.
  • Drive any clock from an absolute deadline, never from a frame count or a stream of ticks, so it stays correct and self-heals under load.

None of this is flashy, but it was the difference between an overlay that demos well and one that holds up live, on stream, for an hour straight. The deadline-timestamp timer in particular was a satisfying little puzzle: once I stopped asking "what number is it now" and started asking "when does this end," the whole class of skipping bugs just went away.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →