androidinterview.com

Android System Design Interview Questions

Design a real-time Twitter feed timeline. How will you structure the backend? WebSocket or REST, and why?

Tier: Less commonDifficulty: Hard

The honest answer is both, REST for loading the timeline and WebSocket for knowing when to refresh it, not one protocol doing everything. Picking a single protocol for the whole feature is usually the wrong framing this question is testing for.

What I'd clarify first

  • Does "real-time" mean the feed updates live while the user is looking at it, or just that new content shows up quickly the next time they open the app.
  • What's the follow graph shape, a user with a few hundred follows behaves very differently from one following someone with a hundred million followers.
  • Is engagement, likes and reply counts updating live, in scope, or is this just about new posts appearing.

Why not WebSocket for everything

A persistent WebSocket per client sounds like the obvious real-time answer, but a timeline is fundamentally a paginated, scrollable list, and REST with cursor-based pagination is the natural fit for "load the next 20 posts." It's cacheable, retryable, and stateless in a way a socket connection isn't. Holding an open socket for every active user just to serve what's still mostly a request-response access pattern is a lot of server-side connection state for little benefit.

Where WebSocket actually earns its place

The real-time part isn't the timeline content itself, it's the signal that new content exists. A lightweight WebSocket, or Server-Sent Events if the client never needs to push anything back, delivers a small event, "3 new posts," rather than pushing full post bodies over the socket. The client then does a normal REST call to actually fetch that new content when the user chooses to, either by scrolling up or tapping a "new posts" banner. This keeps the socket payload tiny and keeps the actual data fetch on the same cacheable, retryable REST path as everything else in the feed.

The Android specific half of that is the socket's lifetime. It only lives while the timeline is on screen. Once the app is backgrounded, Doze and app standby close it within minutes, so the same new content signal arrives as a data message through FCM instead, and the client catches up with one REST call the next time it opens.

Backend structure

  • Fan-out on write, for most users, when someone posts, the post is pushed into the precomputed timeline of each follower immediately, so reading a timeline later is just reading a list, no computation at request time.
  • Fan-out on read, for accounts with an enormous follower count, fanning out a single post to a hundred million individual timelines on write is its own scaling problem, so those posts are instead merged into a follower's timeline at read time, computed on the fly rather than precomputed and stored everywhere.
  • A notification service, subscribed to new posts for a user's follow graph, that pushes the lightweight "new content available" event over the WebSocket or SSE connection to any client currently online and watching that timeline, and hands the same event to FCM for clients that are not.
  • A reconnect policy, jittered exponential backoff plus a heartbeat, because a socket that is open but dead is worse than one that is closed, and ten thousand clients reconnecting on the same second after a deploy is a second outage.

The code

The client side of both protocols, and the split is visible in the types. The pager is REST and cursor based, with an in flight guard, because a fling fires load more three times before the first response lands. The banner is the socket, and the only thing it carries is an integer.

Deduplication is not defensive here, it is required by the backend design above. Write time fan out retries deliver the same post twice, and an account past the follower threshold gets merged in at read time on top of a precomputed timeline, so the two halves overlap. A map keyed by post id, in insertion order, makes display order and deduplication the same data structure. The reconnect path is worth a look. It asks how many posts exist past the newest one held, rather than replaying missed events, so it is right however long the socket was down. Applying the banner subtracts what actually landed rather than clearing the count, because thirty pending posts fetched twenty at a time would otherwise strand ten of them.

Java

com.androidinterview.timeline.feed.NewPostsBanner.java

package com.androidinterview.timeline.feed;

import com.androidinterview.timeline.model.Post;
import com.androidinterview.timeline.model.TimelineApi;

// Where the socket earns its place, and it is smaller than people expect. The
// event is a number, three new posts, not three post bodies. Every connected
// client would otherwise pay bandwidth for content it will mostly never
// scroll up to see, and the fetch would leave the cacheable REST path for no
// gain.
public final class NewPostsBanner {

    private final TimelineStore store;
    private final TimelineApi api;
    private int pending;

    public NewPostsBanner(TimelineStore store, TimelineApi api) {
        this.store = store;
        this.api = api;
    }

    // One socket frame, one integer.
    public void onSignal(int count) {
        pending += count;
    }

    // A reconnect does not replay the events that were missed. It asks once
    // how many posts exist past the newest one held and takes that number,
    // which is correct however long the socket was down and however many
    // events were dropped.
    public void onReconnect() {
        pending = store.newestId() == null ? 0 : api.countNewerThan(store.newestId());
    }

    public int pending() {
        return pending;
    }

    // The user taps the banner, and only then does content move. Prepending
    // under a reading thumb is the behaviour every feed gets complained about.
    //
    // The count comes down by what actually landed, never to zero. Thirty
    // pending with a page size of twenty leaves ten, and clearing the banner
    // there would strand those ten until the next socket frame.
    public int apply(int limit) {
        String newest = store.newestId();
        if (newest == null) return 0;
        Post.Page page = api.newerThan(newest, limit);
        int added = store.prependNewer(page.posts());
        pending = Math.max(0, pending - added);
        return added;
    }
}

com.androidinterview.timeline.feed.TimelinePager.java

package com.androidinterview.timeline.feed;

import com.androidinterview.timeline.model.Post;
import com.androidinterview.timeline.model.TimelineApi;
import java.util.concurrent.atomic.AtomicBoolean;

// Scrolling down, over REST, because that is what a paginated list wants.
// Cacheable, retryable and stateless, none of which a socket gives you.
public final class TimelinePager {

    private final TimelineStore store;
    private final TimelineApi api;
    private volatile String nextCursor;
    private final AtomicBoolean loading = new AtomicBoolean();
    private volatile boolean exhausted;

    public TimelinePager(TimelineStore store, TimelineApi api) {
        this.store = store;
        this.api = api;
    }

    // The guard is the whole reason this is a class. A fling fires the load
    // more callback several times before the first response lands, and without
    // it the same page is fetched three times and the third one appends over a
    // cursor that already moved.
    //
    // compareAndSet rather than a plain flag, because olderThan blocks, so it
    // is not running on the thread the scroll callback arrives on.
    public int loadMore(int limit) {
        if (exhausted || !loading.compareAndSet(false, true)) return 0;
        try {
            Post.Page page = api.olderThan(nextCursor, limit);
            nextCursor = page.nextCursor();
            exhausted = !page.hasMore();
            return store.appendOlder(page.posts());
        } finally {
            loading.set(false);
        }
    }
}

com.androidinterview.timeline.feed.TimelineStore.java

package com.androidinterview.timeline.feed;

import com.androidinterview.timeline.model.Post;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

// Insertion order is display order, and the map is what makes deduplication
// free. Duplicates are not an edge case here, they are the normal result of
// the backend design. Write time fan out retries can deliver the same post
// twice, and an account past the follower threshold is merged in at read time
// on top of a precomputed timeline, so the two halves overlap.
public final class TimelineStore {

    private final Map<String, Post> posts = new LinkedHashMap<>();

    // Returns how many were actually new, which is what the banner counts and
    // what tells a pager that a page of nothing but duplicates is not the end
    // of the feed.
    public int appendOlder(List<Post> page) {
        int added = 0;
        for (Post post : page) {
            if (posts.putIfAbsent(post.id(), post) == null) added++;
        }
        return added;
    }

    public int prependNewer(List<Post> newer) {
        Map<String, Post> merged = new LinkedHashMap<>();
        int added = 0;
        for (Post post : newer) {
            if (!posts.containsKey(post.id()) && merged.putIfAbsent(post.id(), post) == null) added++;
        }
        merged.putAll(posts);
        posts.clear();
        posts.putAll(merged);
        return added;
    }

    // The id of the newest post held, which is what a catch up asks from.
    public String newestId() {
        return posts.isEmpty() ? null : posts.keySet().iterator().next();
    }

    public List<Post> snapshot() {
        return List.copyOf(posts.values());
    }
}

com.androidinterview.timeline.model.Post.java

package com.androidinterview.timeline.model;

import java.util.List;

// A page of the timeline, fetched over plain REST. The cursor is opaque and
// server issued rather than an offset, because a feed that is being written to
// while you page through it repeats and skips rows under offset paging.
public record Post(String id, String authorId, String text, long createdAt) {

    public record Page(List<Post> posts, String nextCursor, boolean hasMore) {
    }
}

com.androidinterview.timeline.model.TimelineApi.java

package com.androidinterview.timeline.model;

// The REST surface, and it is deliberately small. Two cursor based reads and
// one count, because the socket only ever carries the count.
public interface TimelineApi {

    Post.Page olderThan(String cursor, int limit);

    Post.Page newerThan(String postId, int limit);

    // The socket carries a count, so a catch up after a reconnect is one cheap
    // call rather than a replay of every event that was missed.
    int countNewerThan(String postId);
}

Kotlin

com.androidinterview.timeline.feed.Timeline.kt

package com.androidinterview.timeline.feed

import com.androidinterview.timeline.model.Post
import com.androidinterview.timeline.model.TimelineApi

// Insertion order is display order, and the map is what makes deduplication
// free. Duplicates are not an edge case here, they are the normal result of
// the backend design. Write time fan out retries deliver the same post twice,
// and a very large account is merged in at read time on top of a precomputed
// timeline, so the two halves overlap.
class TimelineStore {

    private val posts = LinkedHashMap<String, Post>()

    // Both return how many were actually new, which is what the banner counts
    // and what tells the pager that a page of pure duplicates is not the end
    // of the feed.
    fun appendOlder(page: List<Post>): Int =
        page.count { posts.putIfAbsent(it.id, it) == null }

    fun prependNewer(newer: List<Post>): Int {
        val fresh = newer.filterNot { it.id in posts }.associateBy { it.id }
        val merged = LinkedHashMap(fresh).apply { putAll(posts) }
        posts.clear()
        posts.putAll(merged)
        return fresh.size
    }

    val newestId: String? get() = posts.keys.firstOrNull()

    fun snapshot(): List<Post> = posts.values.toList()
}

// Scrolling down, over REST, because that is what a paginated list wants.
// Cacheable, retryable and stateless, none of which a socket gives you.
//
// The in flight guard is the whole reason this is a class. A fling fires load
// more several times before the first response lands, and without it the same
// page is fetched three times and the third appends over a cursor that moved.
class TimelinePager(private val store: TimelineStore, private val api: TimelineApi) {

    private var nextCursor: String? = null
    private var loading = false
    private var exhausted = false

    suspend fun loadMore(limit: Int): Int {
        if (loading || exhausted) return 0
        loading = true
        try {
            val page = api.olderThan(nextCursor, limit)
            nextCursor = page.nextCursor
            exhausted = !page.hasMore
            return store.appendOlder(page.posts)
        } finally {
            loading = false
        }
    }
}

// Where the socket earns its place, and it is smaller than people expect. The
// event is a number, three new posts, not three post bodies. Every connected
// client would otherwise pay bandwidth for content it will mostly never scroll
// up to see, and the fetch would leave the cacheable REST path for no gain.
class NewPostsBanner(private val store: TimelineStore, private val api: TimelineApi) {

    var pending = 0
        private set

    // One socket frame, one integer.
    fun onSignal(count: Int) {
        pending += count
    }

    // A reconnect does not replay what was missed. It asks once how many posts
    // exist past the newest one held, which is correct however long the socket
    // was down and however many events were dropped.
    suspend fun onReconnect() {
        pending = store.newestId?.let { api.countNewerThan(it) } ?: 0
    }

    // The user taps the banner, and only then does content move. Prepending
    // under a reading thumb is what every feed gets complained about.
    //
    // The count comes down by what actually landed, never to zero. Thirty
    // pending with a page size of twenty leaves ten, and clearing the banner
    // there would strand those ten until the next socket frame.
    suspend fun apply(limit: Int): Int {
        val newest = store.newestId ?: return 0
        val added = store.prependNewer(api.newerThan(newest, limit).posts)
        pending = (pending - added).coerceAtLeast(0)
        return added
    }
}

com.androidinterview.timeline.model.Post.kt

package com.androidinterview.timeline.model

data class Post(val id: String, val authorId: String, val text: String, val createdAt: Long)

// The cursor is opaque and server issued rather than an offset, because a feed
// that is being written to while you page through it repeats and skips rows
// under offset paging.
data class Page(val posts: List<Post>, val nextCursor: String?, val hasMore: Boolean)

interface TimelineApi {
    suspend fun olderThan(cursor: String?, limit: Int): Page
    suspend fun newerThan(postId: String, limit: Int): Page

    // The socket carries a count, so a catch up after a reconnect is one cheap
    // call rather than a replay of every event that was missed.
    suspend fun countNewerThan(postId: String): Int
}

Tradeoffs I'd call out

  • Fan-out on write vs fan-out on read. Write-time fan-out makes reads trivially fast but multiplies write cost by follower count, which breaks down completely for a very high-follower account. Read-time fan-out avoids that write amplification but makes every read for a heavy-follow user more expensive to compute. Most real systems use both, write fan-out as the default, falling back to read-time merging specifically for accounts past some follower threshold.
  • Pushing full posts over the socket vs pushing a small signal. Pushing full post content over the socket saves a follow-up REST call, but it means every connected client, even one that never scrolls up to see new posts, pays the bandwidth and server cost of receiving them anyway. Pushing a lightweight signal and letting the client pull is more round trips but far cheaper at scale, since most opened notifications never get looked at immediately.

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

At scale, the fan-out choice above is the whole ballgame, a design that only does write-time fan-out will fall over the moment a sufficiently large account posts. Offline, the timeline should render from whatever was last cached locally, with the WebSocket simply reconnecting and catching up on missed "new content" signals once connectivity returns, rather than the app trying to replay every individual event it missed while disconnected. On a poor connection, a dropped socket should degrade gracefully to periodic polling as a fallback, a feed that only updates every thirty seconds because the socket couldn't stay connected is a much better failure mode than a feed that silently stops updating at all.

Watch