The bug that only showed up after I'd already fixed the first one
Preloading a TikTok embed to kill tap-to-play lag in a video feed fixed one problem and quietly introduced a worse one: a visible snap back to frame 0, on every first play. Two wrong fixes before the right one.
On this page · 4 sections
LogeAfrica's home feed is a vertical, swipeable list of real-estate listings, TikTok-style, and a lot of that content is literally a TikTok video — sourced from TikTok, embedded back via TikTok's own player. That player has no usable raw-video API, so playback stays a WebView pointed at https://www.tiktok.com/player/v1/<id>. The problem was tap-to-play lag: tapping a card for the first time meant booting a fresh WebView and a fresh copy of TikTok's player JS, which is a real page load, not instant.
The fix was preloading: mount the WebView for the current card and the next two, muted and hidden behind the cover image, while the user is still watching whatever's currently playing.
1// How many cards past the one currently in view get their TikTok embed loaded quietly in the2// background (in addition to the current card itself), so tapping play is instant instead of3// waiting for a fresh WebView boot. Kept small - each one is a real page load in the background.4const PRELOAD_AHEAD = 2;That part worked on the first try. Scrolling through the feed, the next couple of cards were already loaded by the time you reached them. Then came the first bug report.
Wrong fix #1: autoplay=0
"preload works but the videos dont start from their beginning, way in the middle"
My read at the time: TikTok's player must autoplay the instant the WebView loads, and since a preloading card has no business playing in the background, it had been quietly advancing for however long it sat preloaded before being tapped. The fix I shipped was an autoplay=0 query param on the embed URL.
It did nothing. The user tried again: "didnt work." Same symptom, exactly.
So I went and read TikTok's actual embed-player documentation instead of guessing from the symptom.
1| Parameter | Values | Default | Post Types |2|-----------|--------|---------|-----------|3| autoplay | 0 or 1 | 0 | video |Default is already 0. My fix was a no-op — which is exactly why it changed nothing. Whatever was moving the video off frame 0 during preload, it wasn't the player's own autoplay flag.
Wrong fix #2 was actually most of the right fix
The same docs page had a postMessage command I hadn't used yet:
1{2 "type": "seekTo",3 "value": 20,4 "x-tiktok-player": true5}Instead of fighting the drift, the real fix was to stop caring where the player's position had wandered to and just force it back to zero the first time a card is actually played — once per card, gated by a ref so a later pause/resume by the user doesn't reset it again:
1const postSeekToStart = () => `2 (function() { window.postMessage({ type: "seekTo", value: 0, "x-tiktok-player": true }, "*"); })();3 true;4`;This worked. Sound came back reliably too (a single play/unMute attempt wasn't always honored, so that got a small retry loop at 0/120/400/900ms — a separate, smaller fix). The user confirmed it, moved on to other things, and came back a few messages later with a second report.
Wrong fix #3 didn't exist — the seek fix had a timing hole
"when i tap play, the video is alrady plaing to mid. then it quickly goes back to sart. ITs almost not noticeable but annoying."
Same symptom as the very first bug report. Same fix already in place. Still happening — just faster and less noticeable, which is almost worse, because it would have been easy to decide this was close enough and ship it.
The actual mechanism: a preloaded card's cover image sits on top of the hidden WebView, so whatever the player is doing underneath never shows. The cover came down the instant playing flipped to true — the same render where the seek command was sent. The seek itself still had to travel to the WebView, get parsed by the player, and take effect, which isn't instant. So for the gap between "cover removed" and "seek actually landed," whatever frame the player happened to be sitting on during preload was now visible — then it visibly snapped to frame 0 a beat later.
The fix wasn't a better seek. It was not trusting the seek to be instant:
1// First-ever reveal of a card that was already sitting loaded (preloaded) before this tap: it2// may have drifted off frame 0 while idle in the background, so force it back to 0 and only3// drop the cover-image overlay (see render below) once that's had time to land - otherwise the4// overlay disappears the instant "playing" flips true, exposing whatever stale mid-scrub frame5// the hidden WebView happened to be sitting on, followed by a visible jump back to 0 once the6// seek actually catches up. A card that's revealed via onWebViewLoadEnd instead (still loading7// when tapped) skips this - a fresh load is already at frame 0, nothing to correct.8const revealAfterSeekingToStart = () => {9 webviewRef.current?.injectJavaScript(postSeekToStart());10 setTimeout(() => setReadyToReveal(true), 180);11};readyToReveal — not playing — is what actually controls the cover image now. A card that was already preloaded gets the seek command, then sits behind its cover for 180ms before the overlay comes down, by which point the correction has had time to land off-screen. A card that's tapped before it ever finished preloading skips all of this entirely, because a WebView that just finished its first load is already sitting at frame 0 — there's nothing to correct, so there's nothing to hide:
1const onWebViewLoadEnd = () => {2 webviewLoadedRef.current = true;3 if (!playing || readyToReveal) return;4 // Tapped play before this card ever preloaded - frame 0 already, no drift possible yet.5 setReadyToReveal(true);6};Two different first-play paths, same end state, and only one of them needs to stall for anything.
The actual lesson
It wasn't about TikTok specifically. It was that "the state changed" and "it's safe to show the user" are two different moments whenever a fix depends on something async catching up — and the first two attempts both treated them as the same moment. The first fix tried to prevent the drift at the source (wrong layer — the drift wasn't coming from autoplay at all). The second fix corrected the drift but revealed it exactly when the correction was sent, not when it had landed. The third one just added the missing wait between those two events, and held a cover image in front of the gap.
None of this shows up in a screenshot. A timing bug like this one doesn't photograph — the only real evidence is the sequence of events and the two bug reports that said, almost word for word, the same thing a build apart.