다시 재생된 채팅을 VOD 타임라인에 동기화: 이진 검색을 버린 이유

작성자

카테고리:

← 피드로
DEV Community · Dino · 2026-08-03 개발(SW)

I build NoSub, a browser-based VOD player for Twitch and Kick. It started as something narrow — paste a replay link, watch it in a cleaner player — and stayed narrow for about a week. Then I added chat replay, and that turned out to be the part worth writing about.

Watching an old stream without its chat feels strangely flat. Inside jokes land without context, raids look unexplained, and you cannot tell whether a play was genuinely impressive or routine. The video is only half the broadcast. Putting the messages back, in time with the video, is what makes a recording feel like a stream again.

It is also the part I lost the most sleep over.

The question that framed the problem

Someone asked me exactly the right question about it:

On a seek, do you keep the messages in a sorted structure and binary-search to the new timestamp, or replay from the last known point? And how do you handle the burst when someone scrubs into a hype moment with hundreds of messages landing in one second?

The honest answer is: neither, and nothing special. Both of those are reasonable guesses, and both describe a system more sophisticated than the one that actually shipped. What follows is what it really does, including the places where I chose the simpler thing on purpose.

Anchor to the video timeline, never to the clock

This is the decision everything else depends on.

Each message carries a creation timestamp. The offset that matters is not “how long has this page been open” but “how far into the VOD is this message”:

function chatTimelineSecondsFromDate(dateValue) {
    if (!videoStartTime || !dateValue) return 0;
    const seconds = (new Date(dateValue).getTime() - videoStartTime.getTime()) / 1000;
    return Math.max(0, Math.floor(Number.isFinite(seconds) ? seconds : 0));
}

Enter fullscreen mode Exit fullscreen mode

Because the anchor is the VOD’s own start time, pausing, seeking and quality switching become non-events. There is no drift to accumulate, because nothing is ever measured against the wall clock. A player that syncs against elapsed real time works beautifully until the first pause, and I did not want to spend the rest of the project fighting that.

On a seek, throw the buffer away

Not rewound. Not searched. Discarded:

function handleSeeking() {
    chatActiveSession++;        // invalidates any request still in flight
    chatMessages = [];
    lastRenderedMsgId = null;
    startChatLoop(chatActiveSession);
}

Enter fullscreen mode Exit fullscreen mode

The session counter is the part that matters, and it is the bug I would have shipped if I had not hit it in testing. Requests fired before the seek can still resolve after it. Without that guard, a response for minute 12 arrives while you are watching minute 90 and quietly injects messages from the wrong place. Every async loop that can be restarted needs a way to tell its own stale replies apart, and a monotonic counter compared at the await boundary is the cheapest one I know:

const res = await fetch(requestUrl);
if (chatActiveSession !== sessionId) break;   // a seek happened; this reply is stale

Enter fullscreen mode Exit fullscreen mode

A fresh request then goes out with the new target timestamp and the server paginates forward with a cursor. Scrubbing two hours ahead never replays the two hours in between — it starts clean at the new position.

Rendering is a linear reverse scan, on purpose

The buffer is capped at 600 messages. Finding the last one at-or-before the current playback time therefore costs at most 600 comparisons:

let limit = -1;
for (let i = chatMessages.length - 1; i >= 0; i--) {
    if (new Date(chatMessages[i].created_at).getTime() <= absTime) {
        limit = i;
        break;
    }
}
const subset = chatMessages.slice(Math.max(0, limit - 140), limit + 1);

Enter fullscreen mode Exit fullscreen mode

Binary search is the correct engineering for a large sorted buffer. At 600 entries, on a scan that runs from timeupdate, it optimises something that never appeared in a profile. So this is a tradeoff with a stated condition rather than an oversight: if that 600 cap ever moves, this decision moves with it. Writing the condition down is what separates a deliberate shortcut from technical debt you forgot about.

The burst case is handled by the window, not by throttling

This was the second half of the question, and the answer is almost anticlimactic.

Scrub into a hype moment where several hundred messages land inside one second and you get the ~140 nearest your landing point — not 600 nodes injected into the DOM at once. The fixed window absorbs the spike because it never asks how many messages arrived, only which ones are near the current time.

The same constraint doing two jobs is not a coincidence. Capping the buffer is what makes the linear scan cheap, and slicing a fixed window is what makes the burst harmless. One decision, two problems.

Lookahead: 18 seconds playing, zero paused

const desiredHorizon = currentTime + (video.paused ? 0 : CHAT_LOOKAHEAD_SECONDS);

Enter fullscreen mode Exit fullscreen mode

While playing, the loop keeps roughly 18 seconds of chat ahead of the playhead so messages are already in memory when the video reaches them. While paused, it fetches nothing. A paused player has no reason to keep pulling chat it may never show, and on a platform that rate-limits aggressively, the requests you do not make are as important as the ones you do.

That last point cost me a day, incidentally: one of these platforms signals throttling with 403, not 429, in bursts. A polling loop that treats every failure as retryable will happily make the situation worse. The loop now honours Retry-After and backs off up to a minute.

Where it honestly breaks

  • If the platform never stored the chat for that broadcast, or serves it incomplete, the video plays and the chat comes up short. The player can only show what it receives, and no amount of client-side cleverness invents missing messages.
  • Very long broadcasts stress the assumption behind the 600 cap more than short ones. It still holds, but it is the first thing I would re-measure.
  • Cross-platform chat archives are not equally reliable. One of the two is noticeably patchier on older replays, and there is nothing to be done about it from the client.

What I would do differently

Very little, which surprised me. The two things I would change are both about measurement rather than design: I would have profiled the render scan before assuming it was fine, and I would have written down the 600-message assumption next to the code that depends on it, instead of carrying it in my head for two months.

The chat search came later and turned out to be the feature I use most — search the loaded messages by word or username, and the video seeks straight to that moment. It only works because of the same timeline anchoring: if messages already know where they live in the video, “jump to this message” is a one-line operation rather than a synchronisation problem.

If you build video apps or work with HLS, I would genuinely like to hear where you would push back on this — particularly on the fixed-window rendering, which is the decision I am least sure survives contact with a much larger buffer.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다