SevyDevy

Guide · Frontend

Design a News Feed Frontend: Virtualized Lists, Likes, Caching

27 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • News feed frontend system design plans the browser side of a feed app: how posts load, get drawn, react to taps and stay fast on cheap phones.
  • A virtualized list keeps every post as data but puts only the rows near the screen on the page. In our test, 2,000 posts made 72,005 page elements as a plain list and 223 virtualized.
  • Infinite scroll loads the next page when a hidden marker nears the screen. The browser's Intersection Observer, a built-in watcher, reports that, and a cursor, a bookmark from the server, says where the next page starts.
  • An optimistic update shows a like at once, before the server confirms it, and puts it back if the save fails. A normalized cache keeps each post once by id, so every screen shows the same count.
  • Beginners can stop after the section on cheap phones. Engineers then get four bugs we reproduced in Chromium and Node: loading that stalls, a preload margin ignored inside a scroll box, a like race and lost back-button scroll.

What goes wrong when a news feed grows long on a phone?

A long feed gets slow because every post you scroll past usually stays on the page. The browser keeps all of them in memory, long after they leave the screen. News feed frontend system design is planning the browser side of a feed so this does not happen.

Picture a common setup. A neighbourhood community app shows local posts: water supply timings, lost pets, festival plans and plumbers worth calling. Most of its 1.2 lakh (120,000) users open it in a phone browser, often on a Rs 9,000 Android phone with 3 GB of memory. The setup is made up, but the problems are real.

The first version was a plain list. Every new page of 20 posts was added at the bottom, forever. Three complaints kept coming back.

  • After about 400 posts, the feed stutters. On some phones the browser runs out of memory and reloads the whole tab.
  • On a slow network, the heart on a post takes almost a second to turn red. People tap again and undo their own like.
  • Opening a post and pressing back throws you to the top of the feed, and every post loads again from scratch.

None of these is a server problem. The feed API answers in about 120 ms (made up). The trouble lives in the browser, in the UI layer, and that layer is what this guide designs.

Deciding whose posts go into each feed is the server side, a separate topic called news feed system design. Here, a feed API already returns posts page by page.

What is news feed frontend system design?

News feed frontend system design is planning the client side of a feed: the parts of the app, the data they keep and how they talk to the server. It decides how posts load, draw, change and survive the back button.

You usually split it into four jobs. Each one gets its own section below.

  • Rendering: draw a list that may grow to thousands of posts without slowing down.
  • Loading: fetch the next page before the reader reaches the end.
  • Interactions: likes, comments and new posts that feel instant, even on a slow network.
  • State and caching: keep data in memory, share it across screens and restore the feed after the back button.

The team's requirements shaped every choice. The feed must scroll smoothly on a 3 GB phone after an hour of use. A like must show within 100 ms. Back must return to the same post, and screen readers must work.

Here are the parts of the feed client. Read the diagram from the feed screen downwards.

Parts of the feed client

Notice that the post card never calls the server itself. Screens read from one store, and only the network layer talks to the server.

Diagram connections:

  • Feed screen to Virtual list
  • Virtual list to Post card (Fills rows)
  • Post card to Feed store (Reads post, sends taps)
  • Feed store to Network layer (Fetch or change)
  • Network layer to Feed API
  • Post card to Image CDN (Loads photos)
  • Feed store to IndexedDB on the phone (Saves a copy)
  • Feed screen: Home feed for one neighbourhood
  • Virtual list: Draws only the rows near the screen
  • Post card: Text, photo, like and comment buttons
  • Feed store: Posts by id, feed lists, cursors, scroll state
  • Network layer: Fetches, retries, cancels old requests
  • Feed API: Returns pages of posts with a cursor
  • Image CDN: Serves photos resized for phones
  • IndexedDB on the phone: Last 50 posts and unsent drafts

The feed store is the centre of this design. A store is one shared place in the app that holds data and tells screens when it changes. The image CDN (content delivery network) is a set of servers close to users that store and send photos.

What is a virtualized list and why does a feed need one?

A virtualized list keeps only a small, fixed number of rows on the page and fills them with whichever posts are near the screen as you scroll. The other posts exist only as data in memory, not as elements on the page.

Elements are what the page is built from. The browser turns your HTML into the DOM (document object model, a tree of every element on the page). In our test, one simple post card with an avatar, name, text, photo box, a menu button and six action buttons held 36 elements.

So 2,000 plain posts put 72,005 elements on the page, measured in headless Chromium 141, the browser engine behind Chrome run without a window. The same 2,000 posts in a virtualized list left 223 elements, at the top of the feed and at post 2,000 alike.

How a ropeway maps onto a virtualized list

Think of a ropeway at a hill station. It carries thousands of riders a day with a few dozen cabins. Nobody builds a cabin for each rider: each cabin loops round and takes the next family up.

Map it part by part. The riders are the posts, all 2,000 of them. The cabins are the handful of rows on the page, 6 to 10 in our tests. The cable's loop is the scroll: when a row leaves the top of the screen, it is refilled with the next post coming up from below.

Where the picture breaks: a cabin is the same size for every family. A row must change its height to fit each new post. That is why a virtualized list needs a height map, which the next part explains.

How does the list know which posts to draw?

The list needs three facts: how tall each post is, how far the reader has scrolled and how tall the screen is. From those it works out the visible range, meaning the first and last posts that overlap the screen.

What a virtualized list keeps and draws

Thousands of posts live as data. Only a small window of them becomes real rows, and the spacer keeps the scrollbar the right size.

Diagram connections:

  • Height map to Visible range
  • Scroll position to Visible range
  • Visible range to A handful of real rows (Which posts to draw)
  • 2,000 posts as data to A handful of real rows (Fills each row)
  • Height map to Spacer height (Adds up)
  • 2,000 posts as data: Plain objects in the feed store, cheap
  • Height map: Measured or estimated pixels per post
  • Scroll position: How far down, and the screen height
  • Visible range: Posts 41 to 52, plus 3 extra each side
  • A handful of real rows: The only posts that exist as elements
  • Spacer height: Sum of all heights, keeps scrollbar true

Overscan means drawing a few extra rows above and below the screen. Without them, a fast flick shows blank space for a moment before new rows fill in. The team draws 3 extra rows on each side.

Here is the visible-range maths in TypeScript. offsets[i] is the top edge of post i in pixels. The array has one extra entry at the end, which holds the total height.

typescript
// offsets[i] = top of post i in px; last entry = total height
function findFirst(offsets: number[], y: number): number {
  let lo = 0, hi = offsets.length - 2;
  while (lo < hi) {
    const mid = (lo + hi + 1) >> 1;
    if (offsets[mid] <= y) lo = mid; else hi = mid - 1;
  }
  return lo;
}

export function visibleRange(offsets: number[], scrollTop: number,
                             screenHeight: number, overscan = 3) {
  const count = offsets.length - 1;
  if (count <= 0) return { start: 0, end: -1 };
  const first = findFirst(offsets, scrollTop);
  let last = first;
  while (last < count - 1 && offsets[last + 1] < scrollTop + screenHeight) last++;
  return { start: Math.max(0, first - overscan),
           end: Math.min(count - 1, last + overscan) };
}

findFirst is a binary search: it halves the range each step instead of checking posts one by one. The return line widens the range by the overscan and clamps it, so it never runs past either end.

What just happened? We ran it on 2,000 posts, every third with a photo, using 140 px for text posts and 420 px for photo posts. At scrollTop 30,000 on an 800 px screen it returned posts 125 to 134, so 10 rows. The search never took more than 11 steps.

We also checked it against a plain scan of every post at 8,807 scroll positions, with overscan 0. They agreed every time. An empty list returns start 0 and end -1, so nothing is drawn.

What about posts with different heights?

Real posts differ in height, so the list starts with an estimate and corrects it after drawing. The team estimates 140 px for a text post and 420 px for a post with a photo.

After a row is drawn, the app measures it with a ResizeObserver, a browser feature that reports when an element changes size. The height map gets the real number, and every offset after that post moves.

The risk is a jump when a post above the screen turns out taller than its estimate. Good list code shifts the scroll position by the same amount, so the post you are reading stays still.

In a plain list the browser can do this for you. It is called scroll anchoring, and MDN says it is on by default in browsers that support it. MDN's compatibility data lists Chrome 56, Firefox 66 and Safari 27, as of 19 October 2026.

We tested it in Chromium 141. A post above the screen grew by 280 px. scrollY is how far the page has scrolled, in pixels. With scroll anchoring on, scrollY moved from 2,800 to 3,080, and the post on screen did not move. overflow-anchor: none is the CSS rule that turns scroll anchoring off. With it, that post slid 280 px down.

Is there a lighter option than a virtualized list?

Yes, for medium-sized feeds the CSS rule content-visibility: auto can be enough. It lets the browser skip layout and painting for posts that are off screen, while the posts stay on the page.

MDN says such off-screen content stays in the DOM and the accessibility tree. The accessibility tree is the version of the page that screen readers use. Find-in-page and moving with the Tab key keep working.

You pair it with contain-intrinsic-size, a placeholder size used until the post has been drawn. MDN's example, auto 500px, keeps each post's real size after it is first drawn. Full support for content-visibility: auto arrived in Chrome 85, Firefox 125 and Safari 26, per MDN's compatibility data as of 19 October 2026. Safari 18 to 18.6 had it in part: off-screen posts could not be found with find-in-page.

The catch is the element count. In our test the same 2,000 posts with content-visibility: auto still had 72,006 elements. It saves drawing work, but it does not shrink the page.

Four ways to show a long feed of 2,000 posts (element counts measured on our 36-element test card in headless Chromium 141; your cards will differ)
Page buttonsPlain infinite scrollcontent-visibility: autoVirtualized list
Elements on the pageAbout 720 (estimated), one page of 2072,00572,006223
Growth as you scrollNoneGrows with every pageGrows with every pageStays flat
Find-in-page across postsCurrent page onlyWorksWorks, except Safari 18Only drawn rows
Screen reader effortLowLowLowMust keep the focused post drawn
Build effortLowLowVery low, one CSS ruleHighest, heights and measuring

Infinite scroll loads the next page when a small hidden marker near the end of the list comes close to the screen. The browser's Intersection Observer watches that marker and calls your code when it crosses into view.

The older way measured element positions inside a scroll handler. MDN explains that this code runs on the main thread, the single line of work that also handles taps and drawing. Checks that fire on every scroll make the page sluggish.

The hidden marker is called a sentinel. The team gives the observer a rootMargin of 1,500 px, which grows the watched area past the bottom of the screen. So the next page starts loading while the reader is still a few screens away.

Each request carries a cursor: a bookmark the server sent with the last page, such as the time and id of the last post. A page number counts from the top, so new posts at the top shift every page down.

We simulated it. Three new posts arrived between page 1 and page 2. Asking for page 2 by position repeated 3 posts the reader had already seen. Asking with the cursor repeated none.

Follow one load from scroll to new rows. The question box matters most.

Loading the next page

The observer starts the load early. The question box stops two requests for the same page, so only one runs at a time.

Diagram connections:

  • Reader scrolls down to Sentinel within 1,500 px
  • Sentinel within 1,500 px to Already loading?
  • Already loading? to Ignore this call (Yes)
  • Already loading? to Fetch next page by cursor (No)
  • Fetch next page by cursor to Add posts, keep new cursor
  • Add posts, keep new cursor to Draw rows that are visible
  • Reader scrolls down: Reader → Browser
  • Sentinel within 1,500 px: Browser → Observer callback
  • Already loading?: Observer callback → Feed store
  • Ignore this call: Feed store → Observer callback
  • Fetch next page by cursor: Feed store → Feed API
  • Add posts, keep new cursor: Feed API → Feed store
  • Draw rows that are visible: Feed store → Virtual list

That guard is easy to forget. The observer calls you once as soon as you start watching, even when the marker is far away. It calls you again each time the marker enters or leaves the zone. So check isIntersecting, which is true when the marker is inside, and a loading flag.

The team also designed what the bottom of the list shows in each state.

  • Loading: three grey skeleton cards shaped like real posts, so nothing jumps when they fill in.
  • Error: a short message and a Retry button, never a spinner that turns forever.
  • End of feed: a line saying you are all caught up, and no more requests.
  • Offline: saved posts stay readable, with a note that new ones will load when the network returns.

How do optimistic updates work for likes and comments?

An optimistic update changes the screen as soon as the user acts, before the server confirms it. If the save fails, the app puts things back and tells the user.

Likes fit this well: they almost never fail, and a wrong heart for two seconds hurts nobody. Here is the flow.

An optimistic like

The heart turns red before the server answers. A failure or a timeout puts the saved snapshot back.

Diagram connections:

  • Tap the heart to Save snapshot, update now
  • Save snapshot, update now to Send PUT like
  • Send PUT like to Server said OK?
  • Server said OK? to Keep it, use server count (Yes)
  • Server said OK? to Restore snapshot, show note (No, or timed out)
  • Tap the heart: Reader → Post card
  • Save snapshot, update now: Post card → Feed store
  • Send PUT like: Feed store → Feed API
  • Server said OK?: Feed API → Feed store
  • Keep it, use server count: Feed store → Post card
  • Restore snapshot, show note: Feed store → Post card

Here is a first version in code. cache is a Map of posts by id, render redraws the screen and showToast shows a short message. The key step is taking a snapshot before you change anything.

typescript
type Post = { id: string; likedByMe: boolean; likeCount: number };
declare function showToast(message: string): void; // your app's toast

export async function toggleLike(cache: Map<string, Post>, id: string,
                                 render: () => void): Promise<void> {
  const before = cache.get(id);               // snapshot for rollback
  if (!before) return;
  const liked = !before.likedByMe;
  cache.set(id, { ...before, likedByMe: liked,
                  likeCount: before.likeCount + (liked ? 1 : -1) });
  render();                                   // heart changes now
  try {
    const res = await fetch(`/api/posts/${id}/like`,
                            { method: liked ? "PUT" : "DELETE" });
    if (!res.ok) throw new Error(`status ${res.status}`);
    cache.set(id, (await res.json()) as Post); // server's count wins
  } catch {
    cache.set(id, before);                    // put the old state back
    showToast("Could not save your like. Please try again.");
  }
  render();
}

The line that sets before is what makes rollback possible. PUT and DELETE are HTTP methods: here PUT means liked and DELETE means not liked, never a toggle. Both are idempotent, which MDN defines as one request having the same intended effect on the server as several identical ones.

So a retried PUT cannot flip the like back. We ran this function against a fake server in Node 22: a 500 error and a network error both put the heart back and showed one message.

Libraries such as TanStack Query (v5 docs, as of 19 October 2026) build these steps in. Their guide snapshots the old value, rolls back on error and cancels any refetch already running, so old data cannot overwrite the optimistic value.

When should you not be optimistic?

Be optimistic only when failure is rare and cheap to undo. A like, a bookmark or hiding a post all qualify. Deleting a post, paying to boost a post or reporting a neighbour do not, so wait for the server and show progress.

New comments sit in between. The app shows a new comment at once, slightly faded, with a temporary id made on the phone. When the server returns the real id, the store swaps it in. If sending fails, the comment stays with a Retry button, so the text is never lost.

How should a feed frontend cache data?

A feed frontend should keep a normalized cache: every post stored once by its id, and every feed stored as a list of ids. Screens read posts from this one map, so a change shows everywhere at once.

Normalized simply means no copies. The same post can appear in the home feed, a neighbourhood feed and its own detail page. If each screen kept a copy, a like on the detail page would leave the home feed showing the old count.

The table shows where the app keeps each kind of data. IndexedDB is a database built into the browser that keeps data after the tab closes. MDN notes that how much space it gets, and what is deleted when space runs out, differs between browsers.

Where the feed client keeps its data (time limits made up for this design)
DataWhere it livesHow longWhy
Posts by idIn-memory feed storeUntil the tab closesOne true copy for every screen
Feed id lists and cursorsIn-memory feed storeUntil the tab closesSame feed after the back button
Scroll offset and height mapIn-memory, one per feedUntil the tab closesPuts the reader back on the same post
Last 50 home feed postsIndexedDB1 dayInstant first screen, readable offline
Avatars and photosBrowser HTTP cache, via the CDNSet by response headersNo repeat downloads
Unsent posts and commentsIndexedDBUntil sentText survives a closed tab

What is stale-while-revalidate?

Stale-while-revalidate means you show saved data straight away, even if it may be old, and fetch fresh data quietly in the background. RFC 5861 (May 2010) defines it as an HTTP Cache-Control extension, and client caches borrow the same idea.

When a reader opens the app, the last 50 posts appear from IndexedDB at once. At the same moment, the store asks the API for the newest page. The reader is never staring at an empty screen.

How do new posts arrive without moving the feed?

New posts should never be pushed in above the post someone is reading. The app shows a small pill at the top that says how many new posts are waiting. Tapping it scrolls to the top and adds them.

Fresh posts go into the cache right away, but the visible list keeps its order until the reader asks. This keeps their place, and it stops a tap landing on a post that just slid under a finger.

How do you make a news feed accessible to screen readers?

Give the list the ARIA role feed, make each post an article, and tell assistive tools each post's position and the total. The W3C feed pattern describes this for lists that load more as you scroll.

ARIA is a set of labels you add to HTML so assistive tools understand it. A screen reader is software that reads the page aloud for people who cannot see it well. The app follows the pattern like this.

  • aria-posinset gives each article its position, such as 41. aria-setsize gives the number loaded so far or the feed's total, or -1 when the total is unknown.
  • Set aria-busy to true on the feed while posts are added or removed, then back to false. The pattern warns that otherwise some users may not see the changes.
  • Page Down and Page Up move focus to the next and previous post, as the pattern specifies.
  • Give each article aria-labelledby, pointing at its author line, so users can skim from post to post.

Virtualization needs extra care here, because rows that leave the page also leave the accessibility tree. The pattern makes your code responsible for loading or removing articles based on which one has focus. So keep the focused post drawn, and test each release with a screen reader on a real phone.

How do you make a feed fast on cheap phones and slow networks?

Send less, draw less and do less on the main thread.

  • Ask the image CDN for photos sized for a phone, about 720 px wide here, not the 4,000 px original.
  • Add loading="lazy" to feed images. MDN says the browser then waits until the image nears the viewport (the part of the page you can see), and only when JavaScript is enabled.
  • Load the post composer and photo editor only when someone taps the button to create a post.
  • Keep the first page small, about 10 posts, so the first screen appears quickly.

Why does an infinite scroll feed stop loading new posts?

It stops because the Intersection Observer reports changes, not states. If a new page is too short to push the sentinel out of the watched zone, the sentinel never leaves and re-enters. So no new callback comes, and the feed shows no more posts even though the server has more.

Pause and predict. The screen is 800 px tall, the rootMargin adds 1,500 px, and the first page is 500 px of posts. You load 5 more short posts. Does the observer call you again?

It does not. In Chromium 141 the first callback said isIntersecting true. After 5 more posts the sentinel sat at 1,000 px, still inside the zone, and no callback came. Only after 20 more posts did a callback report false.

The fix is to check again after every page lands. Call unobserve and then observe on the sentinel. MDN says the callback runs the first time a target is observed, and in our test that fresh call said true straight away.

Why does rootMargin do nothing inside a scroll box?

Many feeds scroll inside their own box, a div with overflow set to auto, not the whole page. With the default root, the viewport, the 1,500 px margin grows only the viewport. MDN explains that every ancestor box that clips its overflow, such as a scroll box, clips the target first.

We put a sentinel 1,000 px below the visible bottom of a 600 px scroll box. With the viewport as root and rootMargin 1,500 px, isIntersecting was false. With the scroll box as root, it was true.

A newer option, scrollMargin, grows each scroll box instead and also read true in our test. MDN's compatibility data lists it in Chrome 120, Firefox 141 and Safari 26, as of 19 October 2026. Passing the box as root works in every browser with the observer.

Why can an optimistic like show the wrong heart?

It happens when two requests for the same post are in flight and the older reply lands last. The first version writes whatever the server sent, so a stale reply can overwrite the reader's newer tap.

Pause and predict. A reader taps like and then unlike within 5 ms. The like reply is slow, 300 ms, and the unlike reply is fast, 20 ms. What does the screen show at the end?

In our Node 22 test with the first version, the screen ended on liked, 11, while the fake server held not liked, 10. Even with both replies fast, the heart flickered back to liked before settling.

A second trap hides in the server. MDN notes that a repeated DELETE will likely return 404 even though it is idempotent. The first version treats any non-OK answer as failure, so a 404 put a like back that the server had already removed.

The fix is to keep only the latest wish for each post and run one request at a time. When a reply lands, send again only if the wish changed meanwhile.

typescript
type Post = { id: string; likedByMe: boolean; likeCount: number };
declare function showToast(message: string): void; // your app's toast

const wanted = new Map<string, boolean>(); // latest wish per post
const sending = new Set<string>();         // posts with a request running

export async function tapLike(cache: Map<string, Post>, id: string,
                              render: () => void): Promise<void> {
  const shown = cache.get(id);
  if (!shown) return;
  const liked = !shown.likedByMe;
  wanted.set(id, liked);
  cache.set(id, { ...shown, likedByMe: liked,
                  likeCount: shown.likeCount + (liked ? 1 : -1) });
  render();                                // heart changes now
  if (sending.has(id)) return;             // the running loop sends it
  sending.add(id);
  let saved = shown;                       // last state the server agreed
  try {
    for (;;) {
      const want = wanted.get(id)!;
      const res = await fetch(`/api/posts/${id}/like`,
                              { method: want ? "PUT" : "DELETE" });
      if (!res.ok) throw new Error(`status ${res.status}`);
      saved = (await res.json()) as Post;
      if (wanted.get(id) === want) break;  // no newer tap meanwhile
    }
  } catch {
    showToast("Could not save your like. Please try again.");
  } finally {
    wanted.delete(id);
    sending.delete(id);
    cache.set(id, saved);                  // server state, or rollback
    render();
  }
}

What just happened? We re-ran the same fake server tests. Like then unlike ended on not liked, 10, matching the server. Three fast taps sent one request and ended on liked, 11. A failed second request rolled back to the last state the server agreed and showed one message.

The client cannot fix the 404 case alone. Agree with the backend that a repeated like or unlike answers 200 with the post's current state, so a retry never looks like a failure.

How do you restore the feed's scroll position after the back button?

Keep the feed in the store, not in the feed screen, and set the scroll position yourself after the rows are drawn. In our Chromium 141 test, the browser's own restore worked only when the page was already tall enough at that moment.

We built a small single-page app in Chromium 141, scrolled to 2,800 px, opened a post and pressed back. When the feed was redrawn from memory straight away, the browser restored scrollY to 2,800.

When the feed was redrawn only after a 100 ms fetch, scrollY came back as 0, the top of the feed. The likely cause: the browser restored the position while the page was still empty, so there was nowhere to scroll to.

The fix that worked was setting history.scrollRestoration to manual, which MDN says turns the browser's restore off. Save scrollY when the reader opens a post, and scroll to it after the rows are drawn. That test came back at 2,800.

A virtualized list needs one more step: restore its height map first, so the offset points at the same post. Check it yourself by logging window.scrollY before opening a post and after pressing back.

How do you explain news feed frontend system design in a system design interview?

Walk through it in the order a user feels it: first screen, scrolling, tapping, coming back. Then name the trade-off behind each choice.

  1. Ask about users and devices

    Ask how far people scroll, which phones they use, and whether posts carry photos or video. The answers decide whether you need a virtualized list.

  2. Draw the client parts

    Feed screen, virtual list, post card, feed store, network layer and API. Say that the store owns all fetching.

  3. Explain rendering

    A virtualized list with overscan, measured heights and reserved photo space, with content-visibility as the lighter option.

  4. Explain loading

    A sentinel watched by an Intersection Observer, cursor pagination, one request at a time, and a re-check after each page so loading never stalls.

  5. Explain interactions

    Optimistic likes with a snapshot and rollback, one request per post at a time, and a server that answers repeats with success.

  6. Explain caching and recovery

    A normalized cache, stale-while-revalidate on open, a new-posts pill, manual scroll restore and limits on cache size.

  7. Close with what you would measure

    Element count after a long scroll, scroll smoothness, time to the first post and failed likes, all on real low-end phones.

Questions people ask

Do I always need a virtualized list for a news feed?
No. If people rarely scroll past a hundred or so posts, a plain list with content-visibility: auto is often enough. It saves drawing work but keeps every element on the page. Virtualize when sessions are long, posts carry photos, or users have low-memory phones. Measure the element count after a long scroll first.
What is the difference between pagination and infinite scroll?
Pagination shows one page at a time with buttons to move between pages. Infinite scroll adds the next page automatically as you reach the end. Infinite scroll suits casual browsing of a feed. Pages suit search results, where people want to come back to a known place.
What happens to an optimistic like if the phone goes offline?
The request fails, so the app rolls the like back and shows a short message. In our test a network error did exactly that. A more advanced app saves the like in a small queue and sends it when the network returns. Do that only for actions that are safe to send late.
Should I build my own virtualized list or use a library?
Use a well-tested library in a real product, because measuring heights, keeping scroll steady and handling focus have many edge cases. Building a small one yourself is still worth doing once, since interviews often ask for it. Knowing the visible-range maths helps you debug any library.
How many posts should a feed load per page?
Load enough to fill two or three screens, often 10 to 20 posts, and start the next request well before the reader reaches the end. Smaller first pages make the first screen appear faster. Check after each page that the sentinel left the zone, or loading can stall.
Is stale-while-revalidate safe for a news feed?
Yes, for most feed content, because showing posts a few minutes old is fine while fresh ones load. It is not safe for data that must be exact right now, such as a wallet balance. For those, show a loading state and wait for the server.

Sources and further reading

Practise this with Lala

Practise