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

Design a social feed that shows posts from accounts a user follows. Users should be able to load the latest posts, scroll through older ones and learn when newer posts become available while the feed is open.

The problem

Cover the Android timeline, the APIs that serve it and the backend work needed to assemble it. Decide whether new posts appear automatically or behind a prompt. Pagination must avoid losing or duplicating posts while the underlying feed changes.

For example, Ana opens the newest twenty posts and scrolls down. A followed account publishes another post. The app can show a New posts prompt without moving what Ana is reading. Opening that prompt fetches the new content. After a disconnect, the app must catch up on anything it missed.

The protocol question follows from these operations. Loading a page and receiving a notification of change have different needs. Agree on ordering, freshness and scale before choosing REST, WebSocket or a combination.

Starting the design

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.

On Android, I'd keep the timeline socket tied to the visible screen and release its subscription when the screen stops. Background execution and network access are restricted, so the socket cannot be assumed to stay alive. FCM can signal new posts. When the user returns, REST checks for missing content because a push can be late or lost.

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;
    }
}
package com.androidinterview.timeline.feed;

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

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;
    }

    public void onSignal(int count) {
        pending += count;
    }

    public void onReconnect() {
        pending = store.newestId() == null ? 0 : api.countNewerThan(store.newestId());
    }

    public int pending() {
        return pending;
    }

    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);
        }
    }
}
package com.androidinterview.timeline.feed;

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

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;
    }

    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());
    }
}
package com.androidinterview.timeline.feed;

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

public final class TimelineStore {

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

    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;
    }

    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) {
    }
}
package com.androidinterview.timeline.model;

import java.util.List;

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);
}
package com.androidinterview.timeline.model;

public interface TimelineApi {

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

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

    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
    }
}
package com.androidinterview.timeline.feed

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

class TimelineStore {

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

    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()
}

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
        }
    }
}

class NewPostsBanner(private val store: TimelineStore, private val api: TimelineApi) {

    var pending = 0
        private set

    fun onSignal(count: Int) {
        pending += count
    }

    suspend fun onReconnect() {
        pending = store.newestId?.let { api.countNewerThan(it) } ?: 0
    }

    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
}
package com.androidinterview.timeline.model

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

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

    suspend fun countNewerThan(postId: String): Int
}

How I'd build this on Android

Timeline use casesThe system boundary Timeline holds the use cases Browse cached feed, Load older posts, Reveal new posts, Like a post. Reader takes part in Browse cached feed, Load older posts, Reveal new posts, Like a post.
Timeline use cases, a use case diagram for Timeline
Timeline Android componentsFeed route to FeedViewModel, collect and refresh. FeedViewModel to FeedRepository, paged data. FeedRepository to Room and network, save posts and feed order.collect and refreshpaged datasave posts and feed orderFeed routeLazyColumn and new post bannerFeedViewModelPagingData and signal stateFeedRepositoryPager and foreground subscriptionRoom and networkRemoteMediator, REST and signals
Timeline Android components
Each arrow shows a call from one component to another.

I'd use MVVM with a FeedRepository, Room and Paging. The post and its position in a feed are separate records. FeedEntry(accountId, feedId, postId, rank) stores the server's order, while RemoteKey(accountId, feedId, nextCursor) stores where to fetch the next page. Sorting by a post's creation time would lose the ranking, and one post may appear in several feeds.

@Dao
interface FeedDao {
    @Query("""
        SELECT posts.* FROM posts JOIN feed_entries ON posts.id = feed_entries.postId
        WHERE feed_entries.accountId = :account AND feed_entries.feedId = :feed
        ORDER BY feed_entries.rank, posts.id
    """)
    fun pagingSource(account: String, feed: String): PagingSource<Int, PostRow>
}

class FeedViewModel(repository: FeedRepository) : ViewModel() {
    val posts = repository.timeline().cachedIn(viewModelScope)
}

@Composable
fun FeedRoute(vm: FeedViewModel) {
    val posts = vm.posts.collectAsLazyPagingItems()
    LazyColumn(state = rememberLazyListState()) {
        items(
            count = posts.itemCount,
            key = posts.itemKey { it.id },
            contentType = posts.itemContentType { "post" },
        ) { index ->
            posts[index]?.let { post -> Text(post.text) }
        }
        if (posts.loadState.append is LoadState.Error) {
            item(key = "append-error") {
                Button(onClick = posts::retry) { Text("Retry loading more") }
            }
        }
    }
}

Here, PostRow is the data needed to draw one post. The repository returns Flow<PagingData<PostRow>>. A Pager reads Room, and RemoteMediator fetches pages from the API. Each response updates posts, feed entries and the next cursor in one transaction. Refreshing one feed replaces its entries, without deleting posts another feed still uses. I'd show separate states for the first load, an empty feed, a failed refresh and a failed next page.

A socket message can show a new posts banner instead of moving rows while the user reads. Tapping the banner refreshes Paging. I'd decide whether that keeps the current position or moves to the top. If the connection belongs to this screen, repeatOnLifecycle can start and stop collection, and a callbackFlow adapter closes the connection when collection ends. A WhileSubscribed timeout may keep it open briefly after the screen stops.

I'd inject the DAO, API, connection factory and current account into the repository, then inject the repository into the ViewModel. Offline likes need saved operation IDs so a retry does not apply the same like twice. The server still decides the final count.

I'd check these cases.

  • A post arriving from both refresh and the socket.
  • An expired cursor.
  • Switching accounts.
  • Refreshing while the next page loads.

Useful measurements are Room query time, time to show cached posts and dropped frames while scrolling. See Paging with network and database.

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