DEV Community

Cover image for How I synced real-time CS2 predictions with Twitch stream delay
Andrei
Andrei

Posted on

How I synced real-time CS2 predictions with Twitch stream delay

I am building elo.market, a real-time prediction system for CS2 streams.

At first, this sounded simple: the game emits events, I create predictions, viewers vote.

elo.market prediction: How many kills streamer will make?

Then I ran into the actual problem: my backend knew about round starts, bomb plants, and round ends before Twitch viewers saw them.

So this stopped being a "realtime UI" problem and turned into a timeline problem.

The hard part was not generating predictions.

The hard part was preserving a believable timeline for each viewer.

Because on Twitch, there is no single "the stream is delayed by X seconds" value.

There are at least two different delays involved:

  1. the broadcaster's intentional stream delay
  2. each viewer's personal playback delay caused by Twitch buffering, network conditions, device behavior, and low-latency mode

If you get this wrong, the whole product feels broken:

  • predictions open too early and spoil what is about to happen
  • predictions close before the viewer even sees the moment
  • odds update before the actual prediction card appears
  • late votes get rejected even though the UI still looks valid

I ended up building a two-layer delay system: part server-side, part client-side.

But the bigger lesson was this: delay was only the visible problem.

The deeper problems were:

  • event ordering
  • stale data
  • fairness in vote validation
  • keeping one backend timeline compatible with many viewer timelines

The Architecture

The event pipeline looks like this:

CS2 GSI -> Oracle -> Redis Streams -> Worker -> Redis Pub/Sub -> API SSE -> Frontend
Enter fullscreen mode Exit fullscreen mode

The important bit is that predictions are created from real game events, not from the Twitch video stream.

That means the backend always knows about a round start or bomb plant before the viewer sees it on Twitch.

So I had to answer a simple question:

When should each viewer see each prediction event?

The answer became:

  • apply the streamer's configured broadcast delay on the server
  • apply the viewer's additional personal delay on the client

That split turned out to be much cleaner than trying to fully solve everything in one place.

The Real Problem Was Not Delay - It Was Causality

At first I thought I just needed to "shift events by X seconds."

That was naive.

What I actually needed was a system where the UI still made sense after delay was applied.

For example:

  • a prediction card should exist before its activity feed starts filling up
  • odds should not update for a prediction the viewer has not seen yet
  • a LIVE prediction should not appear after it has already resolved
  • the frontend and backend should agree on whether a vote is still valid

Once I started thinking in those terms, the architecture changed a lot.

Layer 1: Streamer Delay on the Server

Each streamer can configure a delay in seconds.

I use that value in the SSE layer and buffer most outgoing events before they are sent to clients.

Conceptually it looks like this:

const delayMs = streamerDelaySeconds * 1000;
const delayedEvents$ = streamerEvents$.pipe(delay(delayMs));
Enter fullscreen mode Exit fullscreen mode

This handles the obvious case: if a streamer intentionally runs a 60-second delay, predictions should also arrive roughly 60 seconds later.

But this alone is not enough.

Two viewers can watch the same streamer and still be several seconds apart. One is on desktop with low-latency mode, another is on mobile with buffering, another is watching inside a Twitch extension.

So I needed a second layer.

The First Versions

Initially, I added a frontend control where viewers could manually configure their own extra delay.

It technically worked. It was also bad UX.

Most people do not want to think in terms of:

  • streamer delay
  • personal delay
  • countdown drift
  • whether they should add 3 seconds or 7 seconds

Even worse, negative adjustments were confusing. They mostly changed timers, not reality.

So while the manual control was useful for debugging and edge cases, it was clearly not a good primary solution.

Then I tried a semi-automatic calibration flow.

The system would wait for a real game event from the backend, and then ask the viewer to click when they saw that same moment on stream.

In one iteration, I used round end events for calibration: the backend knew exactly when the round ended, and the viewer clicked when they saw that same moment on stream.

This was much better than a raw slider because the user no longer had to guess.

But it still had problems:

  • it required active user participation
  • it was easy to miss the moment
  • calibration quality depended on reaction time
  • it felt like setup, which is never what you want in a live product

It was a useful bridge, but still not the end state.

The funny part is that the cleaner answer was sitting in Twitch's own APIs the whole time.

I just had not looked carefully enough at the docs.

On the web player side, Twitch exposes hlsLatencyBroadcaster through playback stats.

On the Twitch Extension side, a similar value is available through the extension context API.

So the final detection rule became simple:

userDelay = max(0, viewerLatencyToBroadcaster - streamerDelay)
Enter fullscreen mode Exit fullscreen mode

That made the manual and click-to-calibrate flows much less important. They became fallback ideas, not the main path.

Measuring Delay Was the Easy Part

In the web app, I poll the Twitch player and read hlsLatencyBroadcaster. In the extension, Twitch pushes similar latency through its context API.

I still smooth the values with a rolling median and only apply meaningful changes, because raw samples jump around.

But honestly, delay measurement ended up being less interesting than what came next.

Why I Did Not Bake Delay Into Stored Timestamps

One of the most important design decisions was to keep timestamps pure.

I do not store delayed timestamps in the database.

createdAt, lockedAt, resolvedAt, and closesAt all remain real server-side times.

Delay is applied only at consumption and validation time.

That matters because different viewers can be on different effective delays for the same prediction.

If I had baked delay into stored timestamps, I would have mixed transport concerns with business state and made the whole system much harder to reason about.

Instead, delay is applied in a few explicit places:

  • SSE buffering on the server for streamer delay
  • client event queueing for viewer-specific delay
  • vote validation for grace periods
  • countdown rendering for display

That separation kept the model sane.

Not All Events Can Be Delayed the Same Way

At first glance, you might think: just delay every SSE event by the same amount.

That does not work.

Some events are structural:

  • PREDICTION_CREATED
  • PREDICTION_UPDATED
  • GAME_STATE_UPDATED

Those can be delayed safely.

But some events are highly dynamic:

  • ODDS_UPDATE
  • VOTE_PLACED

If I delayed those blindly on the server, viewers would get stale odds and activity could arrive for predictions they had not seen yet.

So I ended up with a hybrid approach:

  • most game-driven events are delayed server-side by streamer delay
  • ODDS_UPDATE and VOTE_PLACED are sent immediately
  • the client buffers them until the related prediction is actually visible

Conceptually:

const immediateEvents = ['ODDS_UPDATE', 'VOTE_PLACED'];
const delayedEvents = everythingElse.pipe(delay(streamerDelayMs));
Enter fullscreen mode Exit fullscreen mode

For odds updates, I only keep the latest pending update per prediction.

For vote activity, I queue events until the corresponding prediction card has been seen.

That way the UI stays causally consistent:

  • no odds for invisible predictions
  • no activity for predictions that do not exist yet
  • no stale creation-time odds for viewers watching with delay

I also enrich delayed PREDICTION_CREATED events with the latest odds right before sending them. That fixes another subtle issue: by the time a delayed viewer sees a new prediction, the original odds may already be outdated.

This was probably the most important architectural shift in the whole feature.

I stopped thinking in terms of "delay everything" and started thinking in terms of "preserve causal order."

LIVE Predictions Were a Special Edge Case

Some predictions are created in the middle of a round and have a very short countdown window.

For example, a bomb-related prediction might only have a few seconds of real voting time.

That creates a strange edge case: by the time the delayed PREDICTION_CREATED event is ready to be delivered, the prediction may already be resolved.

So before sending delayed live predictions, I check their latest status.

If a prediction is already resolved or canceled, I skip sending the stale creation event altogether.

That avoids showing viewers nonsense like a fresh prediction card for something that already ended.

This is the kind of bug that makes a realtime product feel fake even when the backend is technically correct.

The Backend Also Has to Be Delay-Aware

Synchronizing the UI is only half the problem.

The backend also has to accept votes in a way that matches what the viewer actually saw.

So vote validation uses a delay context:

const graceWindowMs = streamerDelayMs + userDelayMs + bufferMs;
const votable = now < closesAt + graceWindowMs;
Enter fullscreen mode Exit fullscreen mode

That context is used for two important checks:

  1. whether a countdown-based prediction is still votable
  2. whether a locked or even recently resolved prediction is still inside a grace window for delayed viewers

This is what makes the system feel fair.

Without it, the UI could say "you still have time" while the API says "too late".

That mismatch kills trust immediately.

This part is easy to miss when talking about stream delay. People usually think about rendering, but fairness is really a validation problem too.

Time Sync Matters More Than It Looks

Another subtle problem: if the client clock drifts, your countdown math is wrong.

So I also added periodic TIME_SYNC events from the server and calculate countdowns against server-adjusted time instead of raw Date.now().

That sounds minor, but once you are combining:

  • backend event timestamps
  • streamer delay
  • viewer delay
  • countdown windows
  • grace periods

...even small clock differences become visible in the UI.

So there are really three layers here:

  • the game event timeline
  • the delayed delivery timeline
  • the client's local clock

If those are not reconciled carefully, countdowns and vote windows drift apart.

What the Final Model Looks Like

The current system is basically this:

Server-side

  • streamer config defines the base delay
  • API buffers most SSE events by that amount
  • delayed prediction creation gets refreshed with latest odds
  • stale live creations are filtered out

Client-side

  • web reads Twitch player latency via hlsLatencyBroadcaster
  • extension reads similar latency from Twitch extension context
  • a median-filtered calculator derives additional viewer delay
  • events are queued client-side by that extra amount
  • odds and activity are buffered until their prediction is visible

Validation

  • vote windows are checked against streamer delay + viewer delay + buffer
  • countdown rendering uses server-synced time

The result is that two viewers watching the same stream can see the same prediction at different times, and both still get a coherent experience.

That was the real goal.

Not "perfect clocks".

Just a system where predictions feel aligned with what each viewer is actually seeing.

A Few Things I Learned

  1. stream delay is not one number; it is a layered system
  2. measuring delay is easier than preserving event ordering under delay
  3. keeping timestamps pure is much easier than storing delay-adjusted values
  4. you cannot treat all realtime events equally; some need hybrid delivery
  5. fairness lives in backend validation as much as in frontend rendering
  6. Twitch already exposed the latency data I needed - I just found it later than I should have

I still have more technical stories here, especially around dynamic odds under delayed delivery. But the delay problem was the first one that forced me to stop thinking about "realtime" as one timeline shared by everyone.

On Twitch, realtime is per viewer.

And once I accepted that, the architecture got much better.


If you are building anything interactive on top of live streams, I would love to hear how you handled playback delay, event ordering, or fairness windows.

Top comments (0)