Developing Following Feed in a Mobile App
Following feed — technically most complex component of social app. Not because SELECT posts WHERE author_id IN (following_ids) ORDER BY created_at DESC is hard — works to ~10K users. Problem starts when keeping feed real-time, handling celebrities with million followers, and delivering first screen quickly.
Fan-out vs Fan-in: Architecture Choice
Two classical feed formation approaches:
| Fan-out on write (push) | Fan-in on read (pull) | |
|---|---|---|
| Principle | On publish write to all followers' feeds | On feed request assemble from subscriptions |
| Pros | Read fast (ready feed in Redis) | No data duplication, simpler for stars |
| Cons | Write expensive for popular authors | Read slower, complex ranking |
| When | Up to ~100K subscribers | Million-follower authors |
Most apps use hybrid: fan-out for regular users, fan-in for celebrities (>50K followers). Threshold configurable.
For MVP — fan-in sufficient:
SELECT p.*, u.name, u.avatar_url
FROM posts p
JOIN follows f ON p.author_id = f.followee_id
JOIN users u ON p.author_id = u.id
WHERE f.follower_id = :user_id
AND p.created_at < :cursor
ORDER BY p.created_at DESC
LIMIT 20;
Indexes: follows(follower_id), posts(author_id, created_at DESC).
Real-time Updates
Three options:
Pull to refresh — user pulls down, request posts newer than firstPost.created_at. Simplest variant, works everywhere.
WebSocket/SSE — server pushes new posts to client. On receive show "N new posts" banner at top (like Twitter). Client doesn't insert them automatically — only on tap, otherwise feed jumps under finger.
Long polling — compromise without WebSocket.
On iOS WebSocket — URLSessionWebSocketTask. On Android — OkHttp WebSocket. On Flutter — web_socket_channel.
Pagination and Cursor
Must be cursor-based, not OFFSET:
-
cursor=created_atof last post on current page (ISO 8601 string) - Request:
GET /feed?cursor=2024-11-15T10:30:00Z&limit=20 - Response:
{ items: [...], next_cursor: "...", has_more: true }
At OFFSET 100 database reads 2000 rows just to skip. With many subscriptions and posts — seconds of waiting.
Client-side Caching
iOS — save first 50-100 feed posts in CoreData or Realm. On app open — instantly show cache, simultaneously request new posts. When new arrive — quietly insert at start (or show banner). NSFetchedResultsController + NSDiffableDataSourceSnapshot for smooth update without flicker.
Android — Room + Paging 3 with RemoteMediator. Local database — source of truth, RemoteMediator loads from network into Room, Paging 3 renders from Room.
Flutter — Hive or Isar for local cache, flutter_bloc for page state management.
Algorithmic Feed
Chronological feed — basis. If algorithmic needed (ranking by engagement): store score per post, recalculate via worker (BullMQ/Celery) on likes/comments. Client requests feed with sort=ranked parameter. First run — chronological, after data accumulates — switch to algorithmic. Both feeds as separate tabs (Reels vs Following in Instagram).
Scroll and Performance
UICollectionView with UICollectionViewCompositionalLayout and DiffableDataSource — iOS gold standard. Prefetch data via UICollectionViewDataSourcePrefetching. Images — Kingfisher with memory and disk caching.
On Android LazyColumn (Compose) or RecyclerView with ConcatAdapter. Images — Coil with rememberAsyncImagePainter.
Main jerky scroll cause — image decoding on main thread. Kingfisher and Coil do background by default. Custom loading — DispatchQueue.global(qos: .userInitiated).async (iOS) or Dispatchers.IO (Android).
Workflow
Choose architecture (fan-in/fan-out/hybrid) for expected load → API with cursor pagination → feed UI with cache → real-time updates → load testing (k6) on "1000 simultaneous feed requests."
Timeline
Basic feed with pull-to-refresh and pagination — 2-3 days. With real-time WebSocket, cache, algorithmic ranking — 7-10 days. Cost calculated individually.







