androidinterview.com

Android System Design Interview Questions

Design Instagram Stories.

Tier: CommonDifficulty: Hard

Stories look like a media feature, but the design problem underneath is really about prefetching and expiry. The next story has to show instantly when the user taps, and content has to disappear after 24 hours without a client polling for that constantly.

What I'd clarify first

  • Do stories need to support both photo and video, since video adds buffering and playback state the photo case doesn't have.
  • Is view tracking, who's seen a story, part of this design or out of scope.
  • Does the ring UI need to reflect unseen versus seen state per user, which means the client needs to track viewed story IDs somewhere persistent.
  • Are replies, reactions and link stickers in scope, since each one turns a read-only viewer into a screen that also writes.

The client-side model

  • A tray of story rings, one per followed user with an active story, ordered by recency and unseen-first, fetched as a lightweight list, user id, ring thumbnail, has-unseen-content flag, not the actual story media.
  • A story viewer, a full-screen paged view where each page is one story item, auto-advancing on a timer for photos, on video completion for video, and swiping horizontally moves between different users' story sets.
  • A small pool of players, one or two pre-warmed Media3 players reused across pages rather than one per item, because building a player per page is what makes the first frame land late.
  • Aggressive prefetch, the moment a user opens a story, the next one or two items in that user's set, and the first item of the next user in the tray, start downloading in the background, so tapping forward feels instant instead of waiting on a network request mid-tap.

The API and data model

  • GET /stories/tray, returns active story sets for followed users, this is the small, frequently polled or pushed endpoint, no media, just metadata and seen state.
  • GET /stories/{userId}, returns the actual ordered list of media items for one user's active story set, fetched once the tray is loaded or a specific ring is tapped.
  • Server-side expiry, each story item carries a createdAt and the server simply excludes anything older than 24 hours from both endpoints, the client never has to compute or trust an expiry timer itself, which matters since a client clock can be wrong or manipulated.

Tradeoffs I'd call out

  • Prefetching next content vs data usage. Prefetching the next story item the instant the current one starts makes navigation feel seamless, but it means downloading media the user might close out of before ever seeing. Limiting prefetch depth to just the next item or two, rather than a whole user's entire story set at once, keeps this reasonable.
  • Client-tracked seen state vs server-tracked. Tracking which stories a user has viewed locally is simple and fast to check for the ring UI, but it doesn't sync across a reinstall or a second device. Server-tracked seen state is the correct source of truth for anything that matters, like showing the poster who viewed their story, with a local cache as a fast-path for the ring's visual state.
  • Auto-advance timing for photos vs video. A fixed timer, typically a handful of seconds, works fine for photos but is wrong for video, which should advance on playback completion instead. Treating every story item as "advance after N seconds" regardless of media type is a common shortcut that produces a jarring cutoff mid-video.

The code

Two things in this design are not obvious, and both are in the code. The cursor is a single position in the whole tray rather than a page index plus a current user, so asking what comes next crosses into the next person's set for free, which is exactly what the prefetcher needs to know. Crossing forward lands on that person's first unseen item, not their first item, and crossing backwards lands on the previous person's last item, because going back is rewatching. Long press pauses, the timer stops and the player pauses, and the cursor does not move. And the media cache is a sliding window, not an LRU. An LRU keeps what was watched most recently, which in a story viewer is the item behind you, so eviction goes by distance from the cursor instead, and the bytes live on disk with the cache holding only the handles.

Prefetch depth is a function of the connection rather than a constant, and the advance rule is a type rather than a timer, so a video cannot be cut off mid frame by a photo's timeout. The small class that joins the rule to the cursor is where that promise is kept, a late photo timer landing on a video is ignored. The player pool, the tray request and the seen state upload are left out.

Java

com.androidinterview.stories.model.StoryItem.java

package com.androidinterview.stories.model;

import java.util.List;

// The tray is metadata only, no media, because it is hit on every app open and
// every foreground. Media arrives when a ring is actually tapped.
//
// There is no expiry field and no client side age check on purpose. The server
// excludes anything past twenty four hours from both endpoints, so a wrong or
// deliberately altered device clock cannot keep a story alive.
public record StoryItem(String id, String userId, String url, boolean video, int photoMillis) {

    // Five seconds is the usual photo dwell. Video ignores the field.
    public StoryItem(String id, String userId, String url, boolean video) {
        this(id, userId, url, video, 5_000);
    }

    public record StorySet(String userId, String ringThumbUrl, List<StoryItem> items, int firstUnseen) {

        public boolean fullySeen() {
            return firstUnseen >= items.size();
        }
    }
}

com.androidinterview.stories.prefetch.MediaRingCache.java

package com.androidinterview.stories.prefetch;

import java.io.File;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// A window rather than an LRU, and the difference matters. LRU keeps whatever
// was watched most recently, which in a story viewer is the item behind you.
// What is worth holding is the item in front of you, so eviction is by
// distance from the cursor and the cache is a small ring that slides forward.
//
// The bytes are on disk and the map holds the file handle. A video story in a
// heap map is an out of memory crash, not a cache.
public final class MediaRingCache {

    private final Map<String, File> media = new LinkedHashMap<>();
    private final List<String> window = new ArrayList<>();

    // The current item plus everything the plan wants ahead of it. Anything
    // outside the window is deleted, which is what bounds the disk use of a
    // user who sits and watches a hundred stories.
    public void recentre(String currentId, List<String> upcoming) {
        window.clear();
        window.add(currentId);
        window.addAll(upcoming);
        for (String id : new ArrayList<>(media.keySet())) {
            if (!window.contains(id)) media.remove(id).delete();
        }
    }

    public List<String> missing() {
        List<String> ids = new ArrayList<>();
        for (String id : window) {
            if (!media.containsKey(id)) ids.add(id);
        }
        return ids;
    }

    public void store(String id, File file) {
        // A fetch that lands after the cursor moved past it is discarded, so a
        // slow connection cannot push stale media back into the window.
        if (window.contains(id)) media.put(id, file);
        else file.delete();
    }

    public boolean has(String id) {
        return media.containsKey(id);
    }
}

com.androidinterview.stories.prefetch.PrefetchPlan.java

package com.androidinterview.stories.prefetch;

import com.androidinterview.stories.viewer.StoryCursor;
import java.util.List;

// How far ahead to fetch, and it is a network question rather than a product
// one. Two ahead on unmetered is a good trade, the same two on a throttled
// cellular connection spends the bandwidth the user needs for the story they
// are actually watching.
public final class PrefetchPlan {

    public enum Connection {
        UNMETERED(2), CELLULAR(1), SLOW(0);

        final int depth;

        Connection(int depth) {
            this.depth = depth;
        }
    }

    private final MediaRingCache cache;

    public PrefetchPlan(MediaRingCache cache) {
        this.cache = cache;
    }

    // Ids worth fetching now. Anything already in the window is skipped, so
    // moving forward one item costs one fetch rather than a whole new depth.
    public List<String> fetchNow(StoryCursor cursor, Connection connection) {
        cache.recentre(cursor.current().id(), cursor.upcoming(connection.depth));
        return cache.missing();
    }
}

com.androidinterview.stories.viewer.Advance.java

package com.androidinterview.stories.viewer;

import com.androidinterview.stories.model.StoryItem;

// A photo advances on a timer and a video advances when playback ends.
// Treating every item as advance after N seconds is the usual shortcut and it
// cuts a video off mid frame, which is why this is a type rather than a
// constant.
public sealed interface Advance {

    record AfterMillis(int millis) implements Advance {
    }

    record OnPlaybackEnd() implements Advance {
    }

    static Advance forItem(StoryItem item) {
        return item.video() ? new OnPlaybackEnd() : new AfterMillis(item.photoMillis());
    }
}

com.androidinterview.stories.viewer.AutoAdvance.java

package com.androidinterview.stories.viewer;

// Joins the advance rule to the cursor. The photo timer and the player's end
// of media callback both land here, and each one moves the cursor only if the
// current item is the kind that asked for it. A photo timer that fires late,
// after a swipe onto a video, is ignored rather than cutting the video off.
public final class AutoAdvance {

    private final StoryCursor cursor;
    private final Runnable onMoved;

    public AutoAdvance(StoryCursor cursor, Runnable onMoved) {
        this.cursor = cursor;
        this.onMoved = onMoved;
    }

    // What the screen schedules when an item appears. A photo starts its
    // timer, a video starts nothing and waits for the player.
    public Advance rule() {
        return Advance.forItem(cursor.current());
    }

    public void onTimer() {
        if (rule() instanceof Advance.AfterMillis) step();
    }

    public void onPlaybackEnded() {
        if (rule() instanceof Advance.OnPlaybackEnd) step();
    }

    private void step() {
        if (cursor.next()) onMoved.run();
    }
}

com.androidinterview.stories.viewer.StoryCursor.java

package com.androidinterview.stories.viewer;

import com.androidinterview.stories.model.StoryItem;
import com.androidinterview.stories.model.StoryItem.StorySet;
import java.util.ArrayList;
import java.util.List;

// Position in the tray, one place, rather than a page index in the pager and a
// separate idea of which user is showing. Forward off the end of a set moves
// to the next user, which is the behaviour that makes the whole tray feel like
// one continuous thing.
public final class StoryCursor {

    private final List<StorySet> sets;
    private int setIndex;
    private int itemIndex;

    public StoryCursor(List<StorySet> sets, int startSet) {
        this.sets = sets;
        this.setIndex = startSet;
        this.itemIndex = entryPoint(sets.get(startSet));
    }

    // Entering a set forwards resumes at the first unseen item, not at the
    // start. A set already watched through replays from its first item.
    private static int entryPoint(StorySet set) {
        return set.fullySeen() ? 0 : set.firstUnseen();
    }

    public StoryItem current() {
        return sets.get(setIndex).items().get(itemIndex);
    }

    public boolean next() {
        if (itemIndex + 1 < sets.get(setIndex).items().size()) {
            itemIndex++;
            return true;
        }
        if (setIndex + 1 >= sets.size()) return false;
        setIndex++;
        itemIndex = entryPoint(sets.get(setIndex));
        return true;
    }

    // Tap left. Backwards into the previous set lands on its last item, not
    // its first unseen, which is the asymmetry worth naming. Going back is
    // rewatching, so seen state has nothing to say about where to land.
    public boolean previous() {
        if (itemIndex > 0) {
            itemIndex--;
            return true;
        }
        if (setIndex == 0) return false;
        setIndex--;
        itemIndex = sets.get(setIndex).items().size() - 1;
        return true;
    }

    // What the prefetcher wants. The ids ahead of here in viewing order,
    // crossing into the next user's set at the same item next() would land on,
    // because that is exactly what a swipe is about to demand.
    public List<String> upcoming(int depth) {
        List<String> ids = new ArrayList<>(depth);
        int set = setIndex;
        int item = itemIndex;
        while (ids.size() < depth) {
            if (item + 1 < sets.get(set).items().size()) {
                item++;
            } else if (set + 1 < sets.size()) {
                set++;
                item = entryPoint(sets.get(set));
            } else {
                break;
            }
            ids.add(sets.get(set).items().get(item).id());
        }
        return ids;
    }
}

Kotlin

com.androidinterview.stories.model.StoryItem.kt

package com.androidinterview.stories.model

// The tray is metadata only, no media, because it is hit on every app open and
// every foreground. Media arrives when a ring is actually tapped.
//
// There is no expiry field and no client side age check on purpose. The server
// excludes anything past twenty four hours from both endpoints, so a wrong or
// deliberately altered device clock cannot keep a story alive.
data class StoryItem(
    val id: String,
    val userId: String,
    val url: String,
    val video: Boolean,
    val photoMillis: Int = 5_000,
)

data class StorySet(
    val userId: String,
    val ringThumbUrl: String,
    val items: List<StoryItem>,
    val firstUnseen: Int = 0,
) {
    val fullySeen: Boolean get() = firstUnseen >= items.size
}

// A photo advances on a timer and a video advances when playback ends.
// Treating every item as advance after N seconds is the usual shortcut and it
// cuts a video off mid frame, which is why this is a type and not a constant.
sealed interface Advance {
    data class AfterMillis(val millis: Int) : Advance
    data object OnPlaybackEnd : Advance
}

fun StoryItem.advance(): Advance =
    if (video) Advance.OnPlaybackEnd else Advance.AfterMillis(photoMillis)

com.androidinterview.stories.prefetch.Prefetch.kt

package com.androidinterview.stories.prefetch

import com.androidinterview.stories.viewer.StoryCursor
import java.io.File

// How far ahead to fetch, and it is a network question rather than a product
// one. Two ahead on unmetered is a good trade, the same two on a throttled
// cellular connection spends the bandwidth the user needs for the story they
// are actually watching.
enum class Connection(val depth: Int) { UNMETERED(2), CELLULAR(1), SLOW(0) }

// A window rather than an LRU, and the difference matters. LRU keeps whatever
// was watched most recently, which in a story viewer is the item behind you.
// What is worth holding is the item in front of you, so eviction is by
// distance from the cursor and the cache is a small ring that slides forward.
//
// The bytes are on disk and the map holds the file handle. A video story in a
// heap map is an out of memory crash, not a cache.
class MediaRingCache {

    private val media = mutableMapOf<String, File>()
    private var window = emptyList<String>()

    // The current item plus everything the plan wants ahead of it. Anything
    // outside the window is deleted, which is what bounds the disk use of a
    // user who sits and watches a hundred stories.
    fun recentre(currentId: String, upcoming: List<String>) {
        window = listOf(currentId) + upcoming
        (media.keys - window.toSet()).forEach { media.remove(it)?.delete() }
    }

    fun missing(): List<String> = window.filterNot { it in media }

    // A fetch that lands after the cursor moved past it is discarded, so a
    // slow connection cannot push stale media back into the window.
    fun store(id: String, file: File) {
        if (id in window) media[id] = file else file.delete()
    }

    fun has(id: String) = id in media
}

// Ids worth fetching now. Anything already in the window is skipped, so moving
// forward one item costs one fetch rather than a whole new depth.
fun MediaRingCache.fetchNow(cursor: StoryCursor, connection: Connection): List<String> {
    recentre(cursor.current.id, cursor.upcoming(connection.depth))
    return missing()
}

com.androidinterview.stories.viewer.AutoAdvance.kt

package com.androidinterview.stories.viewer

import com.androidinterview.stories.model.Advance
import com.androidinterview.stories.model.advance

// Joins the advance rule to the cursor. The photo timer and the player's end
// of media callback both land here, and each one moves the cursor only if the
// current item is the kind that asked for it. A photo timer that fires late,
// after a swipe onto a video, is ignored rather than cutting the video off.
class AutoAdvance(private val cursor: StoryCursor, private val onMoved: () -> Unit) {

    // What the screen schedules when an item appears. A photo starts its
    // timer, a video starts nothing and waits for the player.
    val rule: Advance get() = cursor.current.advance()

    fun onTimer() {
        if (rule is Advance.AfterMillis) step()
    }

    fun onPlaybackEnded() {
        if (rule is Advance.OnPlaybackEnd) step()
    }

    private fun step() {
        if (cursor.next()) onMoved()
    }
}

com.androidinterview.stories.viewer.StoryCursor.kt

package com.androidinterview.stories.viewer

import com.androidinterview.stories.model.StoryItem
import com.androidinterview.stories.model.StorySet

// Position in the tray, one place, rather than a page index in the pager and a
// separate idea of which user is showing. Forward off the end of a set moves
// to the next user, which is what makes the whole tray feel continuous.
class StoryCursor(private val sets: List<StorySet>, startSet: Int = 0) {

    private var setIndex = startSet
    private var itemIndex = sets[startSet].entryPoint

    val current: StoryItem get() = sets[setIndex].items[itemIndex]

    fun next(): Boolean {
        when {
            itemIndex + 1 < sets[setIndex].items.size -> itemIndex++
            setIndex + 1 < sets.size -> {
                setIndex++
                itemIndex = sets[setIndex].entryPoint
            }
            else -> return false
        }
        return true
    }

    // Tap left. Backwards into the previous set lands on its last item, not
    // its first unseen, which is the asymmetry worth naming. Going back is
    // rewatching, so seen state has nothing to say about where to land.
    fun previous(): Boolean {
        when {
            itemIndex > 0 -> itemIndex--
            setIndex > 0 -> {
                setIndex--
                itemIndex = sets[setIndex].items.lastIndex
            }
            else -> return false
        }
        return true
    }

    // What the prefetcher wants. The ids ahead of here in viewing order,
    // crossing into the next user's set at the same item next() would land on,
    // because that is exactly what a swipe is about to demand. A sequence means
    // the depth is the caller's decision and nothing is built that is not taken.
    fun upcoming(depth: Int): List<String> =
        sets.asSequence()
            .drop(setIndex)
            .flatMapIndexed { offset, set ->
                set.items.asSequence().drop(if (offset == 0) itemIndex + 1 else set.entryPoint)
            }
            .take(depth)
            .map { it.id }
            .toList()
}

// Entering a set forwards resumes at the first unseen item, not at the start.
// A set already watched through replays from its first item.
private val StorySet.entryPoint: Int get() = if (fullySeen) 0 else firstUnseen

What breaks at scale, offline, and on a poor connection

At scale, the tray endpoint is hit constantly, on every app open and foreground, so it needs to be cheap, metadata only, with actual story media fetched lazily and only for the sets a user actually opens, not eagerly for the whole tray. Offline, previously prefetched story items can still play from cache, but the tray itself can't refresh, so the ring UI should clearly reflect "showing what was last synced" rather than implying it's current. On a poor connection, video stories need adaptive quality or a lower-resolution fallback rather than stalling on a single high-resolution stream, and the prefetch-ahead behavior should back off automatically on a detected slow connection, prefetching two items ahead on Wi-Fi is reasonable, doing the same on a throttled cellular connection just wastes the bandwidth the user needs for the story they're actually watching.

Watch