Design TikTok
A full-screen feed of short videos for 1 B daily users, where the next clip starts instantly and every swipe reshapes what comes next.
Last updated 2026-10-26. Difficulty: hard. Patterns: short-video, prefetch, session-ranking, exploration, cdn. Reported at TikTok, Meta, Google, Snap, Pinterest, Amazon.
The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.
Or sit it in the style of TikTok · Meta · Google · Snap, run as that company’s round is reported to go.
The short answer
How to design TikTok in a system design interview, in five steps:
- Scope it. Post a short video, an endless, full-screen For You feed, the feed adapts within the session, followed accounts mixed in, no repeats, likes, comments, shares and follows, view counts, A fair start for new videos and new users, safety before reach.
- Set the targets. Scale: 1 B DAU · 2 M starts/s peak; Swipe to first frame: p95 < 150 ms · < 1 % of swipes stall; Data per start: ~2.5 MB · prefetch waste < 10 %; Upload to watchable: p95 < 60 s after upload.
- Do the numbers. Video starts per second: ~580 k/s avg · 2 M/s peak; Feed pages per second: ~72 k/s avg · 250 k/s peak; Bytes per start and peak egress: ~2.5 MB · ~40 Tbit/s.
- Draw the design. Edge PoPs, Video store, Media pipeline, Video service, Feed service, Model serving, Moderation, Event stream, Engagement service.
- Go deep where it is hard.
- Instant start: prefetch and small pages → Prefetch the first 2 s of the next 3 to 5 videos, depth by network class, from pages of 8 asked for 4 videos early.
- One rendition per clip, not mid-clip ABR → One rendition per video for the app’s network class, chosen at page time, with a fallback for emergencies.
- Watchable within a minute → Upload to the nearest edge during captioning, a fast 540p lane with automated checks beside it, the rest of the ladder later.
- The feedback loop in seconds → Session state updated on every page from outcomes in the request, stream-updated stats in seconds, models retrained daily.
- Cold start: the staged traffic pool → Staged pool: 200 test views to interest- and region-matched users, then 2,000, 20,000 and 200,000 for the top share, with human review before 20,000.
- Building each page: seen sets and slots → Four weekly Bloom filters per user plus the session’s exact served list, read with the session in one round trip.
Functional requirements
- Post a short video. 15 s to 3 min, from a phone, with a caption, a sound and hashtags. Watchable within a minute of the upload finishing, at full quality a few minutes later.
- An endless, full-screen For You feed. One video at a time; a swipe starts the next one instantly, with no spinner, on Wi-Fi or on a weak cellular link.
- The feed adapts within the session. Completions, rewatches, quick skips, likes and shares change the next page of the feed, not tomorrow’s.
- Followed accounts mixed in. New uploads from accounts you follow appear in For You, at most one per page, as well as in a separate Following tab.
- No repeats. A video already watched does not come back for about four weeks, and never within the same session.
- Likes, comments, shares and follows. Each like counts exactly once per user. Like and comment counts show on every video.
- View counts. Shown rounded and approximate, refreshed within seconds; exact numbers for creator analytics come later from batch.
- A fair start for new videos and new users. A video from an account with no followers can reach a large audience if its viewers respond to it. A new user’s feed is personal within minutes.
- Safety before reach. Automated checks before the first view, human review before a video reaches a wide audience, and a removal stops distribution within minutes.
- Out of scope. Live streaming (see Design Twitch), direct messages, search, ads, creation tools and effects, music licensing and creator payouts. Long-video transcoding and ABR (Design YouTube) and ranking models (Recommendation System) only where short video changes them.
Non-functional requirements
- Scale (1 B DAU · 2 M starts/s peak). 50 B video starts and 30 M uploads a day. Every start is megabytes, so bandwidth, not request handling, is the bill.
- Swipe to first frame (p95 < 150 ms · < 1 % of swipes stall). This drives the hardest trade-off in the design: prefetching deeper makes more starts instant, but wastes more bytes on metered data and makes the feed slower to react to what the user just did.
- Data per start (~2.5 MB · prefetch waste < 10 %). Users on metered plans notice an app that eats data. Every byte fetched and never played is also egress paid for.
- Upload to watchable (p95 < 60 s after upload). Creators post and then watch the counter. One rendition and the automated checks are enough to go public; the full ladder follows.
- In-session freshness (every play shapes the next page). The feed is only as fast as its slowest loop that the user can feel. Outcomes ride on the page request, so the next page never waits for a stream.
- Feed page latency (p99 < 200 ms server side). Only the first page of a session is on the critical path; later pages are requested four videos early.
- Safety (checked before view 1 · reviewed before 20 k). A harmful video seen by 200 people is a takedown; seen by millions it is an incident. Reach grows in steps so that review comes before the big ones.
- Availability (99.95 % of opens play within 2 s). Degrade instead of failing: a cached, slightly stale feed or a popular one beats an error on the app’s only screen.
Back-of-envelope estimates
- Video starts per second: ~580 k/s avg · 2 M/s peak. 1 B DAU × 50 swipes = 50 B starts a day ÷ 86 400 ≈ 580 k/s. The evening peak is 2 M/s, about 3.5× the average. Each start is a view, a watch event and, usually, a few range requests for the rest of the clip.
- Feed pages per second: ~72 k/s avg · 250 k/s peak. Pages hold 8 videos: 2 M starts/s ÷ 8 = 250 k pages/s at peak; 50 B ÷ 8 = 6.25 B pages a day, ~72 k/s on average. Every page is filtered and ranked, so small pages cost more server work per video than a 50-video page would. That is the price of a feed that reacts.
- Bytes per start and peak egress: ~2.5 MB · ~40 Tbit/s. Average delivered bitrate ~1 Mbit/s (125 KB/s). A start fetches ~20 s of video: ~15 s watched plus ~5 s prefetched or buffered and never played. 20 × 125 KB = 2.5 MB. At peak 2 M/s × 2.5 MB = 5 TB/s ≈ 40 Tbit/s, ~125 PB a day. This is the single biggest cost in the system.
- Prefetch per swipe: ~250 KB · waste ~6 %. Each swipe moves the prefetch window by one, so one new head enters it: the first 2 s at ~1 Mbit/s ≈ 250 KB (200 KB at 540p on cellular, 375 KB at 720p on Wi-Fi). The waste is the heads still queued when the app closes: 4 × 250 KB ≈ 1 MB per session × 8 sessions × 1 B users ≈ 8 PB a day, ~6 % of egress.
- Uploads and the fast lane: ~350/s avg · ~1 k/s peak. 30 M ÷ 86 400 ≈ 350 uploads/s, ~1 k/s at peak. At ~15 MB per 30-s clip (encoded on the phone at ~4 Mbit/s) that is ~450 TB a day of originals. The fast 540p encode is ~5 core-seconds a clip, so even the peak needs only ~5 k cores; the rest of the ladder (~30 core-seconds) runs later on ~10 k.
- Rendition storage per day: ~300 TB/day. A 30-s clip at 360p (0.4 Mbit/s), 540p (0.8) and 720p (1.5) is 1.5 + 3 + 5.6 ≈ 10 MB. 30 M × 10 MB = 300 TB/day, ~110 PB a year. 1080p and HEVC copies are made only for the ~10 % of videos that pass the pool’s first stage, adding ~20 %. Originals go straight to an archive tier.
- Seen-set memory: ~2 TB · ~6 TB replicated. 50 plays a day × 28 days = 1 400 videos per user. A Bloom filter at 1 % false positives needs ~10 bits per entry: ~1.75 KB per user. 1 B users × 1.75 KB ≈ 1.75 TB, ~6 TB with three replicas: small enough to keep in memory beside the session state.
- Traffic-pool impressions: ~13 B/day · ~1 in 4 starts. 25 M eligible uploads × 200 test views = 5 B. The top 10 % get 2 000 (5 B), the top 5 % of those 20 000 (2.5 B), the top 2 % of those 200 000 (0.5 B): ~13 B of 50 B starts, about 2 slots in every page of 8. Only the first stage is a real cost; later stages show videos that have already proven themselves.
Components
- Edge PoPs (CDN + upload ingest): Hundreds of PoPs close to users, with regional shields behind them. They serve video bytes (heads of popular videos kept at the edge, bodies by range from the shield on a miss) and terminate uploads near the creator, relaying chunks to the regional store over the provider’s backbone. A short round trip is what makes both a 250 KB head and a 15 MB upload fast on a lossy cellular link.
- Video store (object storage · head + body): Originals (archive tier once encoded) and, per rendition, two immutable objects under a versioned path: a head (init segment plus the first 2 s, ~250 KB) and a body (the rest, in 2-s fragments read by byte range). Splitting the head out lets the CDN keep the most-requested bytes of every popular video at the edge while bodies sit at the shields. It is the CDN’s origin.
- Media pipeline (fast lane · ladder · auto checks): Triggered when an original lands. The fast lane decodes once, encodes a 540p rendition in about 2 s on 4 cores and, beside it, runs the automated checks: perceptual hashes against known illegal content, classifiers on a frame a second, speech and caption text, and an audio fingerprint. The slow lane adds 360p and 720p within minutes, and 1080p and HEVC only once the pool promotes a video.
- Video service (sharded SQL · by video id): Source of truth for each video: creator, caption, sound, status (public, restricted, removed), renditions with their real bitrates, plus a per-creator index of recent uploads for the following source. It makes a video public only once the fast lane and the automated checks have both reported, then admits it to the traffic pool. Feed hosts cache its serving records for at most 30 s, so a removal stops new serves within half a minute.
- Mobile app (player · queue · heads): Plays one full-screen video and keeps a queue of the next ones, with the first 2 s (the head) of the next 3 to 5 already downloaded and the next player decoding its first frame, so a swipe shows a picture at once. Asks for the next page of 8 when 4 unplayed videos remain, sending how each recent play went. Uploads in resumable chunks to the nearest edge, starting while the creator is still writing the caption.
- Feed service (per page · ~100 ms): Builds every page of 8, behind the usual API gateway (not drawn). Reads the session, seen filters and features in one round trip, folds in the outcomes the app sent, refreshes candidates when the session has moved, drops anything seen or not public, has ~200 scored, and fills slots for the pool and followed accounts. Then it picks one rendition per video for the app’s network class and signs the URLs. Stateless: the session lives in the online store.
- Model serving (ANN retrieval · GPU ranker): The slow-moving half of recommendation: an ANN index of recommendable videos queried with long-term and session vectors, and a multi-task ranker on GPUs with completion, rewatch, skip, like, share and follow heads. At 250 k pages/s × 200 candidates it scores 50 M rows a second, ~500 GPUs at peak. Retrained and rebuilt daily; within a session only its inputs change, never its weights.
- Moderation (human review queue): Human review for the pool’s winners before they reach 20,000 viewers, about 125 k videos a day ordered by how fast each is climbing, plus user reports and appeals. Verdicts go to the video service: approve, restrict (followers only, never recommended) or remove. The automated checks ran earlier, in the pipeline; this is where people look at what is about to be seen widely.
- Event stream (Kafka · key=user_id): One play event per video started (watch ms, length, loops, skipped within 2 s, rendition, stalls), plus likes and shares, batched by the app: ~2 M events a second at peak in ~500 k requests. Keyed by user so per-user state is updated in order; per-video aggregates are re-keyed and pre-aggregated downstream, so a viral video is never one hot partition.
- Engagement service (likes · follows · comments): Likes, comments, shares and follows, each a row written once per user, keyed by (video, user) or (follower, followee), with like and comment counts kept as sharded counters beside the rows. Serves a user’s followees to the feed at session start, and like counts and “liked by me” for the 8 videos of each page. A like is exact and idempotent; the count shown is rounded and may lag a second.
- Online store (KV · session · seen · stats): In-memory KV with three key families: per-session state (session vector, ids served this session, ~1,500 cached candidates, recent outcomes), per-user seen filters (four weekly Bloom filters, ~1.75 KB) and features, and per-video stats including the approximate view count. A user’s keys share a hash tag, so the user half of a page is one round trip. Losing a session costs a slightly less personal next page, never an error.
- Traffic pool (stages 200 → 200 k): Every new public video starts here with a test audience of 200, drawn from users whose interests sit near its content and, first, in its own region and language. When a stage’s views are in, it compares completion, rewatch, share and skip rates with videos of the same length and topic and promotes the top share to an audience ten times larger: 2,000, 20,000, then 200,000. Nothing passes 2,000 views without human review.
- Training (daily · offline): The slow loop. Joins a day of plays with the features the feed served, retrains the ranker and the two-tower embeddings, rebuilds the ANN index with yesterday’s pool graduates in it, and ships new versions to model serving after offline checks and a canary. Minute-fresh signals never wait for it: they reach the feed through the session and the stream.
- Stream processor (Flink · seconds): Turns plays into state within seconds: adds played videos to the user’s current seen filter, updates recent history and topic affinities, aggregates per-video stats (views, completion, rewatch, quick skips) every 5 s into the online store, and feeds each pool video’s stage counters. It also lands every event in the warehouse that training reads.
User flows
- Swipe: the next video starts instantly. The main read path. Pages of 8 arrive a few videos before they are needed, and the first seconds of the next videos are already on the phone, so a swipe never waits for the network.
- The app asks for the next page when 4 unplayed videos remain, with its network class and how each recent play went. Asking four videos early hides the page’s latency completely: even a fast swiper at 2 s a video gives the server 8 s. The cursor names the page being asked for, so a retried request gets the same page back instead of advancing the session twice. The network class (20th-percentile throughput of the app’s recent downloads, metered or not, data saver) decides renditions and prefetch depth.
- The feed service reads the session, the seen filters and the user’s features in one round trip. All of a user’s keys share a hash tag, so they sit on one shard and come back in ~5 ms. The session holds the exact list of ids served in it, ~1,500 cached candidates, the session vector and recent outcomes. Four weekly Bloom filters cover the last 28 days of plays.
- If the session has moved, it refreshes candidates, and it asks the pool for new videos meant for this user. The session vector shifts with every play: toward clips completed or rewatched, away from quick skips. When it has moved past a threshold, or every 3 pages, a session ANN query brings ~300 fresh candidates; long-term ones stay cached for the session. The pool returns test and boosted videos whose target audience includes this user.
- Candidates already seen, already served this session or no longer public are dropped. Each of ~1,500 candidates is checked against the session’s served list and the four filters, all in memory, in well under a millisecond. Status and renditions come from serving records cached on the feed host for at most 30 s. A Bloom false positive hides an unseen video 1 % of the time, which nobody notices; a false negative would be a repeat, and Bloom filters never give one.
- The survivors are scored with the session’s fresh features, and the page of 8 is assembled. An in-process pre-ranker cuts ~1,500 to 200 and the GPU ranker scores those in ~40 ms. The page gets one test slot and up to one boosted slot from the pool, up to one new upload from a followed account (followees come from the engagement service once per session), and the rest by score, never two in a row by the same creator or with the same sound. Like counts for the 8 come back in the same batch call.
- For each video it picks one rendition for the network class and signs the URLs. Each class has a cap (data saver 360p, metered 540p, unmetered 720p or 1080p on a large screen), and within it the highest rendition this video has whose bitrate is at most two-thirds of measured throughput. One lower rendition comes along as an emergency fallback. A video minutes old may have only 540p, and is served at 540p everywhere rather than held back.
- The app prefetches the heads of the next videos, as deep as its network class allows. Depth 4 on Wi-Fi, 3 on cellular, 2 on a slow link, 1 with data saver. Heads are fetched in queue order and only while the playing video has 4 s buffered, so prefetch never starves what is on screen, and the next player decodes its first frame in the background. On cellular the window holds ~600 KB.
- On a swipe the first frame is already decoded, and the app fetches the body a few seconds ahead. Swipe to first frame is the swipe animation plus one frame, under 100 ms. The body comes in ~4-s ranges while the playhead is within a few seconds of the end of the buffer, never the whole file at once: a third of plays end within 3 s, and every byte past the playhead is thrown away on a swipe.
- An edge miss on a less popular video is filled from the regional shield, then the store. Heads of videos being served in a region stay at its edges; long-tail bodies come from the shield, which coalesces concurrent misses so the store sees about one request per object per region. A shield miss costs ~100 ms, which would hurt only for a head, and heads are fetched seconds before they are needed.
- Post a video, watchable within a minute. The core write. The upload starts before the creator taps Post, one quick rendition and the automated checks run side by side, and the video is public before the full ladder exists.
- While the creator writes the caption, the app uploads the clip in chunks to the nearest edge. The phone has already encoded the clip (~4 Mbit/s 1080p, ~15 MB for 30 s) and starts uploading the moment editing ends, so for most posts the bytes are there before the caption is. Each 1 MB chunk is retried on its own. A PoP 10 ms away recovers from packet loss ten times faster than a region 100 ms away, which is what keeps a lossy uplink busy.
- On Post, the edge writes the original and the post’s details to the regional store, then acknowledges. The caption, sound, hashtags and visibility are written as a small manifest beside the original, so the post is durable the moment the app hears back, even if a core service is slow. The original is replicated across zones first, because it may be the creator’s only copy.
- The pipeline’s fast lane encodes one 540p rendition while the automated checks run beside it. The 540p encode with a fast preset is ~5 core-seconds for a 30-s clip, about 2 s on 4 cores. From the same decoded frames: perceptual hashes against known illegal content, classifiers on a frame a second, speech and caption text, and an audio fingerprint for music. A short clip is one job per rendition; splitting into chunks only pays near the 3-minute end.
- When both have reported, the pipeline writes the video record with its first rendition and the checks’ result. Pass: public, with renditions [540p]. Likely violation: restricted to the creator and queued for review. A match on known illegal content: removed and reported, never served. This happens ~20 to 30 s after the upload completed, and the checks, not the encode, are the long pole.
- The video service admits the new public video to the traffic pool at stage 0. The pool gets the video’s content embedding (frames, audio, caption) to find a test audience, its region and language, and its length bucket to compare it fairly. Followers can get it too, through the creator’s recent-uploads index. The video is now watchable, inside the minute.
- Within minutes the slow lane adds 360p and 720p, and the video service lists them. Until then everyone gets 540p, which beats waiting. The slow lane uses slower presets for better quality per bit at low priority, so a surge of uploads delays full quality, not publishing. 1080p and HEVC are encoded only when the pool promotes the video past stage 0; ~90 % of uploads never need them.
- A new video climbs the pool and goes viral. Cold start and the scale-breaking case. A video from an account with no followers earns each larger audience with its own numbers, passes human review before 20,000 people see it, then has to survive 50,000 starts a second.
- Feeds of users close to the video’s content give it their test slot until it has 200 views. The test audience is chosen by content embedding and region first: viewers whose vectors are near it, in its language, so the 200 views land on people likely to care, and behind a few PoPs. A page carries at most one test video, and the pool hands each one out against its remaining quota.
- Each play’s outcome reaches the video’s stage counters within seconds. Views, completions, rewatches, skips within 2 s, likes, shares and follows, counted per stage and once per viewer. At 200 views a completion rate is known to about ±3 points: enough to separate the top tenth from the middle, not enough to rank within the top tenth.
- With its views in, the pool compares it with videos of the same length and topic and promotes the top 10 %. The score combines completion, rewatch, share and follow rates minus quick skips, each normalised against its cohort, because a 15-s clip completes far more often than a 3-minute one. It uses the lower end of a confidence interval, so 3 shares from 20 views does not beat 30 from 200. Promoted videos get 2,000 more views; the rest stay recommendable at a trickle, and a late surge can re-open promotion.
- Promoted again past 2,000 views, it waits for human review before going to 20,000. About 125 k videos a day reach this gate. The queue is ordered by how fast each is climbing, so a video gaining views quickly gets a person in minutes. While it waits it keeps its stage-1 audience: nothing is lost but time.
- The reviewer approves it; the video service tells the pool, which opens stage 2. Stage 2 is 20,000 views and stage 3 is 200,000, after which the video leaves the pool and competes on its own scores. The pool knows which regions its next views go to before they happen, so it warms the video’s heads at those edges. The pipeline now adds 1080p and HEVC, because this video’s egress will pay for them.
- It graduates and takes off: 50,000 starts a second, served from edge memory. At 50 k starts a second this one video is 2.5 % of all traffic: ~150 k edge requests a second (a head and a couple of body ranges per start) and ~1 Tbit/s. Its heads at two or three renditions are a few hundred KB each, held in memory at every edge in the regions serving it, and each shield fetched each object once.
- Views are aggregated per 5 s before they are written; likes are rows with sharded counts. The stream processor counts views per video per 5 s and writes one increment per window to each of 16 sub-keys once a video is hot: 50 k views a second become ~3 writes a second. A like is a row keyed by (video, user), so it counts once; its counter is sharded and summed on read with a 1-s cache. Both are shown rounded (“1.2M”), so seconds of lag are invisible.
- Weak signal, retries and a degraded feed. The failure path. The queue on the phone is the buffer: a clip steps down one rendition rather than stalling, prefetch stops before playback suffers, a failed page request is retried without advancing the session twice, and a struggling feed serves a stale page rather than none.
- Throughput falls from 4 Mbit/s to 700 kbit/s while a 540p clip plays. The app sees it within one range request. Prefetching further heads stops at once, because the video on screen has priority over everything queued behind it.
- With under 1 s buffered, the app switches the rest of this clip to the fallback rendition. The fallback URL came with the page. Fragments are 2 s and keyframe-aligned across renditions, so the switch happens at the next fragment without a glitch. This is the only mid-clip switch the player makes: an emergency step down, never a step up.
- The user keeps swiping; the heads already queued still start instantly. Three heads are on the phone, so the next three swipes still play from memory, and their bodies are fetched at the fallback rendition. The app also lowers its measured throughput for the next page request.
- The next-page request times out, and the app retries with the same cursor. Retries back off from 1 s. Because the server keys the request by (session, cursor), a retry after the page was already built returns that same page: the session does not record 8 videos as served that never arrived, and the outcomes are not folded in twice.
- The feed service picks 360p for every video on the slow link. At ~700 kbit/s two-thirds is ~470 kbit/s, so 360p (~390 kbit/s) for each video, with heads of ~100 KB and a prefetch depth of 2. A slow link gets fewer, smaller heads instead of stalls.
- If ranking is overloaded, the feed service serves the session’s cached candidates in their last order. The session already holds ~1,500 ranked candidates from its last refresh. Filtered again for seen and status, their stored order is a page a few minutes stale but still personal. With no session at all it falls back to popular in region and language, the path every signed-out user takes, so it is always warm.
- The signal returns; the next page arrives and the app rebuilds its prefetch window. The first downloads re-measure throughput, and the class goes back up on the following page. Videos already queued at 360p stay at 360p: upgrading them would throw away heads that are already on the phone.
- Skips and rewatches reshape the next page. The feedback loop at three speeds. Outcomes ride on the next page request and change it at once; the stream turns them into seen filters and stats within seconds; the models learn from them overnight.
- The user skips three cooking videos within a second each, then watches a cycling clip twice. Every play ends with an outcome: watched 0.8 s of 24 s, skipped; watched 62 s of a 31-s clip, one loop. On a short-video feed these arrive every few seconds, which is why a session can learn so fast: 20 labelled plays in five minutes, where a long-video home page gets one or two.
- The app sends each outcome to the event stream in small batches. Every 4 plays or 20 s, and when the app goes to the background, with client event ids so a retried batch is harmless: ~2 M events a second at peak in ~500 k requests. The same outcomes also ride on the next page request, so the session never waits for the stream.
- The next page request carries the same outcomes, and the feed service folds them into the session. The session vector moves toward the clip watched twice and away from the three skipped, weighted by watch fraction: 0.8 s of 24 s is a clear negative, a loop a strong positive. Per-topic, per-creator and per-sound counters record “cooking: 3 skips”. This is state, not learning: no model changes.
- The session has moved, so candidates are refreshed and the ranker scores them with the new session features. A session ANN query with the new vector brings ~300 candidates near cycling. The session counters and last 20 outcomes are ranker inputs, so cooking videos still in the cached list now score lower. The page has more cycling, a cooking video at most, and its usual test slot: exploration continues, or the session would collapse onto one topic.
- Within seconds the stream processor adds the plays to the seen filter and updates the videos’ stats. Played videos go into this week’s Bloom filter; four weeks later that slice is dropped, which is how a Bloom filter gets a TTL. Per-video completion and skip rates are aggregated per 5 s for the ranker’s features. Until a play reaches the filter, the session’s exact served list keeps it out.
- Overnight, training joins the day’s plays with the features served, and ships new models. The ranker learns patterns across users, say that loops on cycling clips predict follows, and the embeddings place yesterday’s pool graduates near what their viewers also liked. New versions ship after offline checks and a 1 % canary. Daily is enough because the session carries the last minutes and the stream the last hour.
Deep dives
Instant start: prefetch and small pages
The next video has to start the moment the thumb lifts. How, without burning through the user’s data plan?
A swipe gives the network nothing to hide behind. A cold start on a mobile network is a connection, the first bytes and a decoder start: 300 ms on a good day, over a second on a weak link, every few seconds, 50 times a day. A third of plays end within 3 seconds, so whatever the first seconds look like is most of the experience. Meanwhile users on metered plans notice an app that eats data: a whole 30-s clip at 540p is 3 MB, and fetching the next five whole would be 15 MB for videos the user may never reach.
Two observations shape the answer. Only the first seconds must be on the phone before the swipe; after that the network has the length of the clip to keep up, and 540p needs only 0.8 Mbit/s. And the order of the next few videos is known in advance only as far as the server has committed to it: a feed that re-ranks after every few plays cannot also let the phone download deep into a list it may change. Prefetch depth, page size and how quickly the feed reacts are one decision.
- Prefetch the first 2 s of the next 3 to 5 videos, depth by network class, from pages of 8 asked for 4 videos early chosen
- Fetch on swipe, no prefetch rejected
- Prefetch whole clips for the next few videos situational: on unmetered Wi-Fi while charging, for an explicit offline mode, or for just the next video when the link is very fast
- Big pages of 50, prefetched deep rejected
The answer: The app keeps a queue of unplayed videos and, for the first few, a head: the init segment plus the first 2 s at the rendition chosen for its network class, ~200 KB on cellular and ~375 KB on Wi-Fi, stored as its own object so it is one request. Depth is 4 on unmetered Wi-Fi, 3 on cellular, 2 on a slow link and 1 with data saver; heads are fetched in queue order and only while the playing video has 4 s buffered, so prefetch never competes with what is on screen, and the next player is created and its first frame decoded in the background. The app asks for the next page of 8 when 4 unplayed videos remain, which hides the page’s latency behind four videos of watching and keeps the feed only four plays behind the user. During playback the body is fetched in ~4-s ranges a few seconds ahead of the playhead, not whole, because a third of plays end within 3 s. Waste is the heads queued at session end (~1 MB, ~6 % of egress) plus a few seconds of buffer per swipe, and the last session’s heads stay on disk to be offered to the server at the next open, which turns most of that waste into an instant first video.
Why 2 seconds for the head, not 1 or 5?
Two seconds covers fetching the first body range on a reasonable link: 400 KB at 3 Mbit/s is about a second, plus a round trip. With a 1-s head, a slightly slow body fetch stalls a video the user did not skip. With 5 s, each head is two and a half times the bytes, and the waste at session end grows by the same factor. It is also exactly one fragment, so the head ends on a keyframe and the body starts cleanly.
How do you know prefetching is working?
The app reports, per swipe, whether the head was ready, the time to first frame, and any stall in the first 3 s, tagged by network class and queue position. The SLOs are p95 time to first frame under 150 ms and under 1 % of swipes without a ready head. Next to them sits waste: bytes fetched for heads never played and buffers dropped on swipes, as a share of all bytes, per class. A change to depth is an A/B test on those three numbers plus session length.
Why not let the server re-rank the videos already queued on the phone?
Because their heads are already downloaded, and replacing them throws that data away and costs a new head for each. The trade is explicit: the feed reacts after the queued videos, about four swipes, in exchange for instant starts. When a session turns sharply the server can still drop queued videos whose heads have not been fetched, which costs nothing, and the app can skip a queued video locally if its topic is one the user just skipped three times.
You launch in a market where most users are on slow, expensive data. What changes?
Depth drops to 1 or 2 and heads to 360p, ~100 KB, so a swipe still starts from memory on a link too slow to fetch on demand. The body buffer target rises, because throughput is erratic, and the app asks for pages earlier so a long dead zone does not empty the queue. Codecs matter more: an HEVC or AV1 copy of popular videos cuts their bytes by about a third on devices that decode it in hardware.
The app opens after a night offline. What plays first?
The heads still queued from last night, if the server confirms them. The first-page request lists them; the server checks they are still public and unseen and returns them first, followed by new videos. The first video then costs only the page round trip, ~200 ms, not a page plus a head fetch. If the check fails or is slow, the app waits for the page rather than play an unchecked video: a removed video must never play.
One rendition per clip, not mid-clip ABR
YouTube switches bitrate segment by segment. Why pick one rendition per video here, and what happens when the network changes mid-clip?
Adaptive bitrate exists for long videos: an hour of playback has to ride out minutes of changing bandwidth, so the player measures throughput segment by segment, starts low and climbs. On a 30-s clip that machinery mostly hurts. The player needs a few segments to converge, which is a third of the clip; starting low means the first seconds, all that a skipper ever sees, are the worst-looking ones; and a manifest adds a round trip before the first byte. And the heads of the next four videos are fetched before any of them plays, so their rendition has to be chosen before there is anything to adapt to.
The phone is downloading all the time, so it knows its throughput better than a player starting a long video does: the last minute of heads and ranges is a fresh measurement. A 30-s 540p clip is 3 MB and arrives in a few seconds on a 10 Mbit/s link, long before it finishes playing. So the question is not how to adapt within a clip but which single rendition to pick before it starts, and what to do in the rare case the network collapses in the middle.
- One rendition per video for the app’s network class, chosen at page time, with a fallback for emergencies chosen
- Standard HLS or DASH with segment-by-segment ABR situational: for clips longer than about a minute, using the same keyframe-aligned files and their fragment index, so nothing extra is encoded
- Always serve the lowest rendition rejected
- The app picks from the whole ladder for each video situational: as a veto: the app may step down from the server’s choice if its measurement has dropped since the page was built
The answer: The app reports a network class with every page request: metered or not, data saver, and the 20th-percentile throughput of its recent downloads. Each class has a cap (data saver 360p, metered 540p, unmetered up to 1080p on a large screen), and within the cap the feed service picks the highest rendition this video actually has whose bitrate is at most two-thirds of the measured throughput, per video, because a talking head at 720p may need half the bits of a dance clip. The response carries that URL and one rendition lower as a fallback. The clip plays at that rendition unless the buffer ahead falls under 1 s, when the app switches the rest of the clip to the fallback at the next 2-s fragment, which is keyframe-aligned across renditions; it never steps up mid-clip. Files are one head and one body per rendition with a fragment index in the head, so the same files serve segment-level ABR for the rare clip over a minute. On the CDN, choosing per class means two or three renditions of a popular video carry nearly all its traffic instead of five, so its heads stay hot at every edge.
A user starts a 720p clip on Wi-Fi and walks out of range. What happens?
If the link stays above the clip’s bitrate, nothing: the body is fetched a few seconds ahead and 720p needs 1.5 Mbit/s. If the buffer ahead falls under a second, the app switches to the fallback at the next fragment, a step down few people notice on a phone. The next page request carries the new class, so the following videos are chosen for cellular. Heads already fetched at 720p play as they are rather than being thrown away.
Why does the server choose, when the YouTube design rejects server-side ABR?
Server-side ABR there means the server deciding segment by segment without seeing the buffer, which is wrong. Here the server makes one choice per video before playback, from the app’s own measurement, and knows things the app does not: which renditions this video has yet, each one’s real bitrate, and the cost policy for the class. The app keeps a veto and can step down. Decisions that need the buffer stay on the phone.
How many renditions do you encode, and why so few?
Three for every video, 360p, 540p and 720p, ~10 MB for a 30-s clip, because most uploads are watched a few hundred times and every rendition is storage forever. 1080p and HEVC are added when the pool promotes a video past stage 0, about 10 % of uploads, where the egress saved pays for the encode. HEVC cuts bytes by roughly a third at equal quality on devices with hardware decoders, which on a popular video is worth far more than the encode.
How would you know the class thresholds are right?
From the app’s per-play reports: rendition, stalls, time on the fallback and watch time, by class, device and carrier. If a class sends more than a fraction of a percent of plays to the fallback, its threshold is too greedy; if an A/B test giving a class a higher rendition raises watch time with no rise in stalls, it was too cautious. Thresholds are server config, so they change per country without an app release.
Watchable within a minute
A creator taps Post. Why not encode the full ladder and then publish, and how do you reach watchable in under a minute without skipping the safety checks?
Creators post and then watch the counter. A video that is not live for ten minutes feels broken, and a trend that is hot this hour is cold tomorrow. But 30 M uploads a day at ~15 MB is 450 TB of originals arriving over mobile uplinks, and every one must be checked before anyone sees it: a single illegal or dangerous clip shown to a test audience is an incident, however small the audience.
The time goes in three places: getting bytes off the phone, encoding, and checking, and each can overlap with the others. Bytes can move while the creator writes the caption. A viewer needs only one decent rendition to watch, and a 540p encode of a 30-s clip is a few seconds of CPU. And the automated checks need decoded frames, not a finished ladder, so they can run beside the first encode instead of after it.
- Upload to the nearest edge during captioning, a fast 540p lane with automated checks beside it, the rest of the ladder later chosen
- Encode the full ladder, then publish rejected
- Serve the phone’s own encode as the first rendition situational: never as served bytes; the phone’s encode is the fast lane’s input, which is part of why the fast lane is fast
- Chunk-parallel encoding for every upload situational: for uploads near the 3-minute end, split into 4 or 5 chunks, as in the YouTube design
The answer: The app encodes on the phone (1080p at ~4 Mbit/s, ~15 MB for 30 s) and starts a resumable upload to the nearest edge PoP as soon as editing ends, so for most posts the bytes have arrived before the caption. Post sends the caption and settings to the same edge with a client post id, and the edge writes the original and a manifest to the regional store, replicated, before it acknowledges. The fast lane decodes once and does two things with the frames: it encodes 540p with a keyframe every 2 s (about 2 s on 4 cores), and runs the automated checks: perceptual hash matching against known illegal content, classifiers on a frame a second, speech and caption text, and an audio fingerprint. When both report, the video service makes the video public with one rendition, restricted pending review, or removed, and the pool admits public ones to stage 0. The slow lane adds 360p and 720p within minutes at low priority, and 1080p and HEVC only when the pool promotes the video. The target is public within 60 s of upload at p95; typical is 20 to 30 s, and the checks, not the encode, are the long pole.
The upload dies at 80 %. What does the creator see?
A paused upload, not a failure: the app keeps the upload id, asks the edge which 1 MB chunks arrived, and sends the rest when the connection returns, even after a restart. The post is not acknowledged until the original is assembled and replicated, so “uploading” is honest. Incomplete uploads expire after 24 hours.
The classifiers are slow today: 2 minutes instead of 15 s. What do you do?
Keep the gate and miss the minute. Publishing first would make the first 200 test viewers the moderation system, which this design exists to prevent. Scale the classifier fleet on queue depth, keep the cheap and decisive hash matching in its own lane so known-bad content is still caught in seconds, and alert on the share of posts taking more than 60 s to go public.
Why upload to an edge PoP instead of straight to the region?
On a lossy mobile link, upload throughput is limited by round-trip time, because TCP and QUIC recover from loss one round trip at a time, and a PoP 10 ms away recovers ten times faster than a region 100 ms away. The edge relays over the provider’s backbone, which is fast and clean. It also gives one upload endpoint worldwide. The original lands in one home region, chosen by the creator’s location, which is also where its first viewers are.
A creator with 20 M followers posts. Do the followers see it before review?
Twenty million followers is a wide audience, so following must not become a way round the review gate. Accounts with a long clean record can reach followers before review at a capped rate, with their uploads first in the review queue so a person looks within minutes; accounts without that record are held to the same stage limits as anyone. The trade is minutes of delay on some posts against harm that a later takedown cannot undo.
The feedback loop in seconds
A user skips three cooking videos in a row. How quickly does the feed notice, and what actually changes: the model, or something else?
Short video produces labels faster than any other feed: a play every 15 s on average, each with a clear outcome (skipped at 0.8 s, watched to the end, looped twice, liked, shared). Twenty labels in five minutes say more about what this person wants now than a week of history does. If the feed learns only overnight, the user spends the evening swiping past a topic they rejected at 7 pm.
Three different things can change, at three speeds. The session’s state: a vector and some counters for this sitting, changed on every page. Shared statistics: a video’s completion rate, a user’s recent history and seen set, changed within seconds by the stream. And the models’ weights, which learn general patterns and are the most expensive and riskiest to change. The design question is which signal goes into which loop, and where in a request each is read.
- Session state updated on every page from outcomes in the request, stream-updated stats in seconds, models retrained daily chosen
- Train the model online from the event stream, syncing parameters to serving every minute situational: after the session and stream loops are in place, starting with the sparse id embeddings, behind quality guardrails that can freeze syncing
- Precomputed feeds refreshed hourly rejected
- Send a long ranked list and re-rank it on the phone situational: as a light local filter: skip a queued video whose topic the user just skipped three times
The answer: Every page request carries the outcomes of the plays since the last one (id, watch ms, length, loops, skip, like, share), and the feed service folds them into the session before ranking: the session vector moves toward embeddings of completed and looped clips and away from quick skips, weighted by watch fraction, and per-topic, per-creator and per-sound counters record this session’s reactions. Those counters and the last 20 outcomes are ranker inputs, so the same model scores the same candidate differently after three skips, and when the vector has moved enough a session ANN query brings new candidates. Separately, the stream processor updates seen filters, recent history and per-video stats within seconds, which is what matters across sessions and devices and for the pool. Models retrain daily on served features joined with outcomes and ship by canary. ByteDance’s 2022 Monolith paper describes training a recommendation model online from a Kafka stream of user actions, with a collisionless embedding table (cuckoo hashing, an id getting an embedding only after it has appeared often enough, and embeddings expiring when their ids go quiet) and parameters synced to serving at minute-level intervals. I would treat that as the next step, starting with the embeddings of new videos, not the first thing to build.
The stream processor falls 10 minutes behind. What breaks?
The session’s reaction does not, because outcomes arrive with the page request. Seen filters lag, but the session’s exact served list still keeps this session’s videos out, so only a second device might see a repeat for a few minutes. Per-video stats go stale, which slows pool promotions and blunts trending. Every stream feature carries its update time, so an alert on feature age fires before users would notice.
How do you stop one evening’s mood from overwriting long-term taste?
Session state lives in the session and expires 30 minutes after the last activity, so it shifts this sitting only. The long-term vector moves only through daily training on weeks of history, where one evening of skipped cooking is a small signal. The ranker sees both and learns from data how much to trust each, and test slots keep sampling topics the session has turned away from.
Why are watch fraction and loops the core labels rather than likes?
Every play has them and likes are rare, perhaps one play in twenty. A quick skip is an explicit negative on almost every swipe, which a scrolling feed never gets. Completion has to be normalised by length, since a 10-s clip completes far more often than a 3-minute one; TikTok has said publicly that finishing a longer video is a strong signal of interest. Likes, shares and follows get heads of their own, weighted higher because they cost the user more to give.
How do you count 2 M views a second and show a number on every video?
A view is not a row. The stream processor counts views per video per 5 s, once per (viewer, video, day), and writes one increment per window, so the long tail costs one write per video per window and a viral video a few writes a second spread over 16 sub-counters summed on read. The count is shown rounded, so seconds of lag and a little loss on failover are invisible, and creator analytics are recomputed exactly from the warehouse each day.
Someone runs bots that watch a video a thousand times to push it up the pool. What stops them?
Session effects are per session, so a bot only games its own feed. For the pool, plays count once per (viewer, video, day) and only from accounts and devices with normal histories; bursts from new accounts on one network are discounted by the abuse pipeline before they reach the stage counters. Human review before 20,000 views is a second look at anything climbing suspiciously fast.
Cold start: the staged traffic pool
Thirty million videos arrive a day, most from accounts with few followers. Which get shown, to whom, and how does a great video from a nobody reach millions without a harmful one doing the same?
New videos have no history, and history is what ranking runs on. Showing every upload widely is impossible: 30 M uploads into 50 B starts a day is ~1,700 starts each before any proven video gets one. Showing none means the feed recycles what is already popular and new creators stop posting. And the cost of a mistake grows with reach: a bad video seen by 200 people is a takedown; seen by 2 million it is the news.
Audience can be bought in steps, and each step pays for the information to decide the next. At 200 views a video’s completion rate is known to about ±3 points, enough to separate the top tenth from the rest; at 2,000 the top tenth can be ranked. Because a short video’s outcomes arrive within seconds of each view, a stage takes an hour, not a week. And each step says where the next audience is, which matters for review and for the CDN.
- Staged pool: 200 test views to interest- and region-matched users, then 2,000, 20,000 and 200,000 for the top share, with human review before 20,000 chosen
- Distribute to followers only, and let shares carry it rejected
- One bandit over all new videos, with per-video budgets situational: inside a stage, choosing which test videos each user gets (Thompson sampling on predicted engagement), while the stages still set reach and review
- Uniform random exposure for new videos rejected
The answer: About 25 M of the 30 M daily uploads are public and pass the automated checks; each enters stage 0 with a quota of 200 views. Its audience is drawn from users whose session or long-term vectors are near the video’s content embedding (frames, audio, sound, caption, hashtags), in its language and region first, at most one test video per page of 8. When the views are in, usually within the hour, the pool scores the video on completion, rewatch, share, follow and quick-skip rates against a cohort of the same length bucket and topic, using a lower confidence bound so a lucky small sample does not win, and promotes the top 10 % to 2,000 views. The top 5 % of those go to 20,000 after human review, and the top 2 % of those to 200,000, after which the video leaves the pool and competes on its own scores: ~13 B starts a day in all, about 2 of every 8. A video that misses a gate stays recommendable at a trickle, and a later surge in shares re-opens promotion. New users get the mirror image: a first page spread across popular topics in their region, then a session vector after a few plays, since 20 swipes in five minutes say a lot. TikTok has said publicly that follower count is not a direct factor in For You recommendations; the stages and numbers here are my design.
What does a video with 200 views cost on the CDN, and how do you keep it low?
About 200 × 2.5 MB = 500 MB of egress, but what hurts is cache misses: if 200 views landed on 200 PoPs, every one would miss. Drawing the test audience from the video’s own region and language first puts most views behind one shield and a handful of PoPs, so the store serves each object about once. When a video is promoted to stage 2 or 3, the pool already knows which regions its next 20,000 or 200,000 views will come from and warms its heads there first.
A spammer uploads 500 near-identical videos to farm test views.
Quotas scale with creator trust: beyond a few uploads a day, a new account’s videos get smaller test audiences, and near-duplicates (close perceptual hash and content embedding) share one test instead of getting 500. Spam classifiers in the automated checks drop the obvious cases. Trust is about abuse, not popularity, so a new honest creator still gets a full test.
How do you know the gates promote the right videos?
Look at what happens after promotion: a video promoted to stage 2 should keep its rates at stage 2, and if promoted videos regularly fall back to the cohort average, the gate is rewarding noise and the bound should be stricter. Keep a small holdout of videos that missed a gate but are promoted anyway, to measure how many good videos the gate buries. And track the share of new creators whose first video reaches 2,000 views.
A pool video is removed after review. How fast does it stop playing?
New serves stop within 30 s, when feed hosts’ cached serving records expire. Copies already queued on phones are dropped by the next page response, which names any removed ids among those the app reported as queued. Signed URLs expire after 6 hours, and for illegal content the edge purges the video’s objects by prefix, so even a saved URL fails within minutes.
Ten times the uploads tomorrow. Does the pool still work?
Stage 0 would need 50 B test views, all of the traffic, so either the quota per video shrinks or the bar for entry rises. I would cut stage 0 to 100 views for low-trust creators, let the content model’s predicted quality decide which uploads get a full test, and lean on the bandit inside the stage to spend views where they are most informative. The later stages limit themselves, because they only take a top share.
Building each page: seen sets and slots
Every page of 8 has to be fresh, mixed and never show something the user watched last week. How do you build one in about 100 ms, 250,000 times a second?
A page is small, but every one is ranked: 250 k pages a second at peak, each filtering ~1,500 candidates, scoring ~200 on GPUs and choosing 8 under rules. Two things make it more than the usual funnel. Repeats: a user plays ~1,400 videos in four weeks, heavy users far more, and seeing one again is the clearest sign the feed is broken, yet checking 1,500 candidates against a table of plays on every page is 375 M lookups a second. And mixing: some slots belong to the pool, some to followed accounts, and the same creator or sound must not appear twice in a row.
The seen check only has to be right in one direction: a watched video must never pass, but hiding an unwatched one now and then costs nothing, because there are thousands more. That is exactly a Bloom filter’s guarantee. What a Bloom filter cannot do is forget, yet a feed should be allowed to show a favourite again after a month, and a filter that only grows becomes more and more wrong. Forgetting can be done by time instead of by deletion.
- Four weekly Bloom filters per user plus the session’s exact served list, read with the session in one round trip chosen
- An exact table of plays per user, read on every page rejected
- One growing Bloom filter per user rejected
- A cuckoo filter per user, with deletes situational: if single videos must be un-seen, for example when a user deletes one item from their watch history
The answer: The seen set is four Bloom filters per user, one per week, each sized from the user’s plays the week before at ~10 bits per entry for 1 % false positives (~440 B for an average week). The stream processor adds each play to the current week within seconds, and each week the oldest slice is dropped, so a video can return after four to five weeks. The session keeps an exact list of everything served in it, which covers both the seconds before the stream catches up and videos that were served but never played. A user’s keys share a hash tag, so session, seen slices and features are one round trip (~5 ms), and testing ~1,500 candidates against four in-memory filters takes well under a millisecond. The page then takes ~200 survivors through the GPU ranker (~40 ms; ~500 GPUs at peak), and the mixer fills 8 slots: one stage-0 test, up to one boosted pool video, up to one new upload from a followed account (followees read from the engagement service once per session, their uploads from the creator index), and the rest by score, never two in a row by the same creator or with the same sound, a rule TikTok has described publicly. The budget is ~5 ms state, ~30 ms candidate refresh when it runs, ~10 ms pre-rank, ~40 ms rank and ~10 ms for mixing, renditions and signing: ~100 ms, with p99 under 200 ms.
The node holding a user’s seen filters is lost. What do they see?
Repeats are the visible failure, so a missing filter is never treated as empty. The online store keeps three replicas, so losing one node changes nothing. If every copy is gone, the feed rebuilds the four slices from the user’s watch history (kept for the history page) before building the page, a few milliseconds of hashing for a typical user, and serves from the session’s cached candidates while it runs.
A user deletes a video from their watch history. Should it come back in the feed?
If the product says deleting history means forgetting, then yes, and a Bloom filter cannot remove one entry. The honest options are a small per-user “unsee” list checked before the filters, cheap because such deletes are rare, or rebuilding that user’s slices from the edited history. Both beat moving every user to a deletable structure for a rare action.
Why mix followed accounts into For You at all, rather than leave them to the Following tab?
A new upload from someone you follow is one of the strongest predictors of a watch, and many users never open the Following tab. One slot at most keeps For You from becoming chronological, and the ranker still decides whether to fill it: an account followed two years ago and always skipped loses the slot. Reading followees once per session and their recent uploads from the creator index is fan-out on read, which suits short sessions and avoids writing every upload into millions of inboxes.
Ranking 400 candidates per page instead of 200 doubles GPU cost. How do you decide?
Like any change: an A/B test on watch time and session length, set against ~500 more GPUs at peak. Before paying, check the cheaper route: if the pre-ranker’s top 200 already contains the heavy ranker’s top 8 out of 400 more than 95 % of the time, extra candidates rarely change the page. Small pages make this sharper than on a long feed, because each extra candidate is scored for 8 slots, not 50.
The theory behind it
- Video streaming: transcoding, HLS, DASH and adaptive bitrate: How online video works end to end: uploads, transcoding into a bitrate ladder, segmenting for HLS and DASH, adaptive bitrate in the player, CDN delivery, live versus on-demand, and the numbers behind it.
- Recommendation systems: How large recommendation systems are built: candidate generation, ranking and re-ranking, embeddings and two-tower models, features and feature stores, offline and online serving, feedback loops, cold start, and how to evaluate them.
- Multi-armed bandits and exploration: Learning what works while serving users: the explore-exploit trade-off, epsilon-greedy, upper confidence bounds and Thompson sampling, contextual bandits, bandits versus A/B tests, uses in ranking, notifications, pricing and creative selection, logging for off-policy evaluation, and pitfalls.