Engineering, told slowly

How we build
the feed that never
ends

You've probably scrolled through one today. Maybe for an hour. Content kept arriving, and the feed barely asked for your attention. That quiet experience takes a surprising amount of engineering. Let's build it one problem at a time.

01 The trick you never noticed

It's supposed to feel like nothing

Infinite scroll works best when you barely notice it. You flick your thumb, more posts appear, and you keep going. No "Next Page" button. No long pause. The feed simply flows.

That seamlessness is the product. A blank gap, a jump, or a visible wait breaks the experience. So the real goal is not simply "show more posts." It is this:

Make an endless stream of content feel continuous while the backend fetches, ranks, and renders each new batch.

To do that, the app requests the next batch before you reach the bottom. The backend must decide which posts belong in your feed, preserve their order, and return them quickly enough that the next batch is ready before you need it.

We will start with the simplest implementation, see exactly where it fails, and then replace it with a design that works for a live production feed.

◆ Warmed up. Now the first real problem →
02 The obvious idea

"Just grab the next twenty"

Imagine every post lives in a giant list in the database, newest at the top. When you open the app, we hand you the first 20. You scroll to the bottom, and we say to the database: "skip the 20 you already sent, give me the next 20." Scroll again: "skip 40, give me 20."

This has a name. It's called offset pagination, and it's the first thing almost everyone reaches for. In database language it looks like this:

SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000 /* discard 100,000 rows, then return 20 */
Quick word In this query, OFFSET 100000 tells the database to discard the first 100,000 rows from the sorted result. LIMIT 20 returns only the next 20 rows. In Oracle, the equivalent syntax is OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY.

It's clean. It's easy to understand. You could even build old school numbered pages and jump straight to page 50. For a small app, it works fine.

And then your app gets popular. And it quietly starts to die.

03 Crack #1: it gets slower the deeper you go

The database can't teleport

When you say "skip 100,000 rows," the database still has to locate and discard those 100,000 rows before it can return the next 20. OFFSET does not identify a stable starting row. It only describes how many rows must be ignored in the current sorted result.

The problem

The deeper the offset, the more rows the database must scan and discard before returning the requested page. Eventually a request that took milliseconds can take seconds, time out, or consume enough database work to affect other users.

This is why latency grows with scroll depth. The red bars show an illustrative offset query as the skipped row count increases. The teal bar shows the cursor approach we will build next, where the query starts from a known sort key instead of recounting from the beginning.

Time to fetch the next 20 posts

Page 1
Page 50
Page 500
Page 5,000
Cursor, any depth
Offset: slower every page Cursor: stable at any depth

By page 5,000, the example offset query is over 600 times slower than the cursor query. The exact numbers depend on the database, the index, and the data, but the pattern is what matters. Offset work grows with the number of skipped rows. The cursor keeps the starting point explicit, so the teal bar stays nearly flat at any depth.

04 Crack #2: the feed shifts under your feet

The ghost of the duplicate post

Speed isn't even the worst of it. There's a second crack, and it's the kind that makes users think your app is broken.

Picture this. You have just read posts A through T, the first 20 rows in the feed. While you are reading, five new posts are inserted at the top. That is normal on a live feed. When you scroll again, the app still asks for "OFFSET 20, then return the next 20."

BEFORE NEW POSTS

Positions 1 to 20: A ... T already read
Position 21 onward: U, V, W ...

AFTER FIVE NEW POSTS

Positions 1 to 5: N1 ... N5
Positions 6 to 25: A ... T

OFFSET 20 now starts at position 21:
P, Q, R, S, T, U, V ...

P through T appear again.

The offset did exactly what we asked. It skipped the first 20 positions in the new version of the list. But those positions no longer contain the same 20 posts. The five new rows moved A through T down by five places, so the next page begins at P instead of U. The user sees P through T twice.

The problem

Offset tracks position, not identity. Inserts above the current page can create duplicates. Deletions above it can move unseen posts into the skipped range and create gaps. A live feed changes while the user is reading it.

We now have two failures from the same design. Deep offsets are expensive, and moving positions make pagination inconsistent. Both problems come from using a changing position as the continuation point.

◆ Two problems diagnosed. Now, how do we think about the fix?
05 The shift in thinking

Move from offsets to cursors

Both failures come from using a changing position as continuation state. "OFFSET 20" means "start at position 21 in the list as it exists now." If rows are inserted or deleted, position 21 can refer to a different post.

A cursor stores the sort key of the last row returned. For a feed ordered by created_at DESC, id DESC, the cursor can contain the last post's created_at and id. The next request asks for rows that come after that exact key in the same ordering.

That changes the API from "skip 20 rows" to "continue after this row." The continuation point is tied to a stable value from the result set, not to a position that can move.

The name for it This approach is called cursor based pagination or keyset pagination. It is a common choice for large, continuously changing feeds because it avoids deep offsets and gives each request a stable continuation point.

Now we can build the query and see how the cursor fixes both problems.

06 Building the real thing

The cursor, step by step

Instead of "skip 20," the app now hands the server the bookmark from the last post it received. The server turns that into a plain, honest question to the database: "give me the posts that come after this exact one."

SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

The important part is the WHERE clause. With an index that matches the ordering, for example (created_at DESC, id DESC), the database can seek to the cursor key and scan only the next 20 index entries. It does not need to count and discard every row before the cursor.

Index requirement For this query, the useful index is the same key used by the filter and sort: (created_at DESC, id DESC). The cursor identifies where the index scan should begin, and LIMIT 20 bounds how many rows are read.

Crack #1, sealed. The query begins at the cursor key instead of scanning through every earlier result. Page 5 and page 50,000 perform roughly the same amount of page work.

Crack #2, sealed too. New posts at the top do not change the cursor key. The next request continues after the last row the user actually received, so inserts above it do not move the continuation point.

Why not use only the post ID?

The cursor must represent the same ordering used by the query. Here the feed is ordered by created_at DESC, id DESC, so the cursor carries both values.

You can use only the ID when the feed itself is ordered by ID and those IDs are strictly monotonic. But many systems use UUIDs, random IDs, or an order that does not match ID order. In those cases, the ID alone does not tell the database where the row belongs in the feed.

In this query, created_at preserves the recency order. The id makes that order unique when two posts have the same timestamp. Together they create a deterministic sort order, so the next page cannot lose or repeat rows at the boundary.

We also hide the bookmark

The API normally returns an opaque cursor instead of exposing separate database fields. A simple implementation can serialize the values to JSON, encode them with Base64URL, and optionally attach an HMAC signature. The encoding keeps the client independent of the cursor schema. The signature lets the backend reject a modified cursor. Base64URL by itself is not encryption.

RAW CURSOR PAYLOAD
{"createdAt":"20260407T120000Z","id":456}

API RESPONSE
{
  "items": [ ... ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2MDQwN1QxMjAwMDBaIiwiaWQiOjQ1Nn0.signature"
}

NEXT REQUEST
GET /feed?cursor=eyJj...signature&limit=20

And one clever shortcut: "grab one extra"

How does the app know when to stop, when there is nothing left to load? Counting every remaining post would be expensive. So if the app wants 20 posts, the backend asks the database for 21. If 21 rows come back, there is another page. The backend returns 20 items and sets hasNextPage to true. If 20 or fewer rows come back, it returns them and sets hasNextPage to false.

O(1)
Roughly constant speed, at any depth
0
Duplicates or skips from a shifting feed
+1
Extra row that tells us when to stop
◆ The engine works. But the user still sees the screen, so let's fix that side too.
07 Keeping the screen calm

The illusion has a second half

A fast, stable backend is only half the promise. The other half lives on your screen. And screens have their own breaking point: they choke when asked to hold too much at once.

If you scrolled for twenty minutes and the app kept every post you passed, your phone would be trying to draw tens of thousands of items at once. The browser grinds, scrolling gets choppy, memory balloons, and eventually the whole thing crashes.

The problem

A phone or browser cannot keep thousands of visible item components alive at once without stuttering and consuming too much memory. But a "feed that never ends" seems to demand exactly that.

The fix: only draw what fits on the glass

Here's the sleight of hand. At any moment, only about 20 posts actually fit on your screen. So why keep the other 9,980 drawn? We don't. The app draws only the handful you can see, plus a small buffer just outside the viewport so nothing flickers. As you scroll, it recycles those same item components and binds them to new post data.

To keep the scrollbar honest, the app uses an invisible spacer as tall as all the posts would be. It feels like thousands of items are present, but only a small window of components is mounted at any time. This technique is called virtualization, or "windowing," because the UI renders only the moving window around the current viewport.

The last enemy: the jump

You've felt this one. You go to tap something, an image finishes loading, everything lurches down, and you tap the wrong thing. That lurch is called a layout shift, and it's the fastest way to make an app feel cheap.

The defense is preparation. Before a post's image arrives, the app reserves the exact space it will need, often showing a grey skeleton placeholder in the meantime. It also fetches the next batch early, while you are still a screen or two away from the bottom, so the new posts are ready by the time you arrive. Done right, the seam is invisible. The entire handoff finishes before the user notices it.


08 How the giants really do it

Same idea, planetary muscle

Everything so far is the foundation, and every large feed builds on the same core ideas. At the scale of hundreds of millions of people, platforms add more machinery to decide not just where the feed continues, but what deserves to be in it at all. Here is the high level shape of each approach.

Twitter / X precompute at write time

X figured that people read far more than they post, so it does much of the work when someone publishes, not when a follower scrolls. When a user posts, a service can fan the post ID into ready made follower timelines stored in fast memory, so the feed is already available when those followers open the app.

Very large accounts are an exception. Copying one post into tens of millions of timelines would create a huge write burst, so those posts can be fetched and mixed in at read time instead. The result is a hybrid of prepared timelines and on demand assembly.

Instagram a funnel of rankers

Instagram cannot score billions of possible posts for every request, so it narrows the candidate set in stages. A cheap, fast model reduces the large pool to a few thousand candidates. A slower, more expensive model then ranks the finalists and predicts how likely the user is to like, click, or hide each one.

It also fights repetition. If the ranker optimized only for immediate engagement, it could fill the feed with very similar posts. A diversity adjustment can lower the score of candidates that are too similar to recently selected items and keep the feed varied.

LinkedIn recency and self correction

LinkedIn places strong weight on freshness and uses staged ranking, followed by a final pass that can enforce variety before posts reach the feed.

It can also learn from silence. When a post repeatedly appears without receiving an interaction, that lack of engagement becomes a signal. Future ranking passes can reduce its score and make room for fresher or more relevant candidates.

Notice the shared foundation under all three. They precompute what they can, assemble the rest on demand, rank candidates in stages, and use a stable pagination contract to continue the feed.

09 The whole picture

What "nothing" is actually made of

We started with a feed that felt like nothing to use. Now you know what's underneath: a bookmark instead of a page number, so the database can leap straight to your spot and never trip over a shifting feed. A screen that only ever draws a handful of posts while faking thousands. A frantic little handoff that finishes before you notice it started. And, on top, a ranking brain deciding what's worth your next flick.

The next time content keeps arriving under your thumb, you will know what supports that effortless experience. A large amount of careful engineering is working in the background so the feed itself feels simple.

◆ ◆ ◆