Paging a list of orders is a solved problem. Paging a ranked home feed is not, and I found that out the boring way: a tester scrolled to page three of my Instagram-style feed and saw a post she had already liked on page one.
Nothing was "broken". Every query returned correct data. The feed was simply being re-ranked between requests, so the ground moved under the offset. This post is about that one slice of the problem, the read path and its cursors, and the five mistakes I made before it behaved. The write side (who gets pushed, who gets pulled) is a different story.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, tested project behind this post (
instagram-home-feed-dotnet, Clean Architecture, 238 passing tests, plus a 118-page PDF) is inside. Join Tech Skill Builder
Mistake 1: paging a live query with Skip/Take
The first version looked like every list endpoint I've ever written:
// don't do this for a ranked feed var page = Rank(await LoadCandidatesAsync(viewer, ct)) .Skip(pageIndex * pageSize) .Take(pageSize); Between page one and page three, three things happen: new posts arrive, like counts move, and the ranker's recency term decays. Each one shifts items across your offset. Posts get repeated, and worse, some get skipped and the user never sees them.
Fix: rank once per scroll session and freeze the result. The cursor points into that frozen list, not into a fresh query.
public sealed record FeedSnapshot( string SessionId, UserId Viewer, IReadOnlyList<RankedItem> Items, // frozen order for this session int CaughtUpIndex, // where "You're all caught up" goes PostId? HeadWatermark, // newest id at build time DateTimeOffset BuiltAt, StageCounts Stages, bool Rebuilt); Page N is now just a slice of something that no longer changes. In my project the snapshot lives in a session store for 15 minutes; how long yours lives is a product decision.
Mistake 2: a cursor the client can read and edit
My second version put { session, offset } into base64 and called it a cursor. Base64 isn't a lock. Anyone could change the offset, or swap in another viewer's session id and page through their ranked feed.
Fix: sign it, and bind it to the viewer.
// cursor = base64url(json payload) + "." + base64url(truncated HMAC-SHA256) public bool TryDecode(string? token, out FeedCursor? cursor) { // 1. split on '.' // 2. recompute HMAC over the payload, compare in constant time // 3. reject if expired // ... full implementation in the complete project } Then in the handler, check the owner before you touch the session:
if (!cursors.TryDecode(query.Cursor, out var c) || c is null) return Invalid("cursor is invalid, expired or was modified."); // 400 if (c.Viewer != viewer.Value) return Forbidden("this cursor belongs to another viewer."); // 403 Two different status codes on purpose: a corrupted cursor is a client bug, a foreign one is something you want in your logs.
Mistake 3: splicing new posts into the list someone is reading
Once pages were frozen, the next complaint was "new posts never show up". My first instinct was to merge fresh posts into the current snapshot. That brings back mistake 1 through the side door.
Fix: keep new posts out of the current session and just count them. The snapshot remembers a watermark (the newest post id at build time). Because the ids are time-ordered 64-bit values, "newer than the watermark" is one integer comparison:
// GET /users/{user}/feed/new-posts?cursor=... public async Task<AppResult<NewPostsView>> HandleAsync(GetNewPostsQuery q, CancellationToken ct = default) { // 1. decode + verify the cursor (same rules as paging) // 2. count entries with PostId > the session's HeadWatermark // ... full implementation in the complete project } That number drives a "New posts" pill. Tapping it starts a brand-new session. The list the user is halfway through never moves.
Mistake 4: rebuilding an expired session from scratch
Sessions expire. Someone scrolls, goes to make tea, comes back after 20 minutes and asks for the next page. My first rebuild produced a perfectly good new ranking, starting with the posts they had just scrolled past.
Fix: remember what you served in the session, and exclude it when you rebuild.
snapshot = await sessions.GetAsync(viewer, c.SessionId, now, ct) ?? await builder.BuildAsync(viewer, now, await seen.ServedInSessionAsync(viewer, c.SessionId, ct), // exclude these ct); The user sees no seam. One of the integration tests in the project does exactly this: serve two pages, push the manual clock past expiry, request page three, and assert there are no repeats.
Mistake 5: treating "caught up" as the last page
I originally showed "You're all caught up" when the cursor ran out. But Instagram's own 2018 announcement describes it differently: the message appears once you've seen every post from the last two days, and older or already-viewed posts continue below it. It's a line in the middle of the feed, not the end of it.
Fix: split the ranked list in two, keeping order stable inside each half, and store the split index in the snapshot.
public static (IReadOnlyList<RankedItem> Ordered, int CaughtUpIndex) Apply( IReadOnlyList<RankedItem> ranked, DateTimeOffset now, TimeSpan window) // window = 48h { // fresh = not seen AND created within the window // rest = everything else // return fresh ++ rest (order kept inside each), CaughtUpIndex = fresh.Count // ... full implementation in the complete project } And be strict about what "seen" means. A post the server returned hasn't been seen. Only impressions the client actually rendered, reported via POST /users/{user}/feed/seen, count. Otherwise a fast fling to the bottom marks fifty posts as seen that nobody looked at.
The checklist I use now
- [ ] Ranked once per session, paged from a frozen snapshot
- [ ] Cursor signed (HMAC), expiring, and bound to the viewer
- [ ] 400 for a tampered cursor, 403 for a foreign one
- [ ] New posts counted against a watermark, never spliced in
- [ ] Session rebuild excludes everything already served
- [ ] Caught-up is a split index, based on rendered impressions
None of this is exotic. It's just that a ranked feed punishes every shortcut that a sorted list lets you get away with.
Get the full version
The snippets above are the shape of the solution, with the important bodies trimmed. The complete, working Instagram-style home feed in .NET 10 is in Tech Skill Builder: hybrid push/pull fan-out with hysteresis, time-sortable 64-bit ids, a 700 → 500 ranking funnel with a diversity reranker, signed cursors, sessions and the caught-up split. It follows Clean Architecture (Domain, Application, Infrastructure, Api), runs offline with no keys or database, and ships with 238 tests plus a 118-page PDF that walks through the architecture before any code.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
Which of these five bit you first? I'd bet on mistake 1.

