Developing User Subscriptions (Follow System) in a Mobile App
Subscriptions — foundation of social graph. Technically a table follows (follower_id, followee_id, created_at) with UNIQUE constraint. But UI details, query performance, and notifications determine how well the system works at scale.
Data Schema and Queries
Table follows:
CREATE TABLE follows (
follower_id BIGINT NOT NULL,
followee_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id);
Index on followee_id needed for fast subscriber count: SELECT COUNT(*) FROM follows WHERE followee_id = ?. Without it on 1M records — full scan.
Denormalization: users.followers_count and users.following_count — updated by trigger or queue. Profile counter fetched from denormalized field, not COUNT.
Mutual follow: SELECT 1 FROM follows WHERE follower_id = $1 AND followee_id = $2 — check both directions. Can cache in Redis: SISMEMBER user:{id}:following {target_id}.
Follow/Unfollow Button
Optimistic update mandatory — button toggles instantly:
// iOS
func toggleFollow(userId: String, currentlyFollowing: Bool) {
let optimisticState = !currentlyFollowing
updateFollowButton(isFollowing: optimisticState)
let request = optimisticState ? apiService.follow(userId) : apiService.unfollow(userId)
request.sink(
receiveCompletion: { [weak self] completion in
if case .failure = completion {
self?.updateFollowButton(isFollowing: currentlyFollowing) // rollback
}
},
receiveValue: { _ in }
).store(in: &cancellables)
}
On Compose — followState: Boolean in ViewModel, changes before request, rolls back on error.
Three button states: "Follow," "Following," "Unfollow" (last shown on long-tap or hover). Don't show "Unfollow" as main text — users take it for follow confirmation.
Private Accounts (Closed Profiles)
If app supports private profiles: follow_request instead of immediate follow. New table follow_requests (requester_id, target_id, status, created_at). Target user sees incoming requests, accepts/rejects. On accept — record moves to follows. Push notification on new request and on acceptance.
Followers/Following List
Pagination cursor-based by created_at DESC or follower_id (stable order). For each user in list show isFollowedByMe — requires JOIN or batch check. Batch variant: SELECT followee_id FROM follows WHERE follower_id = ? AND followee_id IN (?) — one query for entire visible page.
On iOS — UITableView with UITableViewDiffableDataSource, each cell contains avatar, name, Follow button with state. Prefetch: UITableViewDataSourcePrefetching requests next page 3 cells from end.
On Android — LazyColumn with Paging 3, RemoteMediator for network + local cache.
Notifications on Follow
New subscription — push notification to profile author: "Ivan followed you." FCM/APNs via backend. Deeplink — to follower's profile. Batching: if 5 people followed within minute — one notification "5 new followers," not five separate.
"Who to Follow" Recommendations
Simple heuristic: users followed by my followers but not by me ("friends of friends"). SQL:
SELECT DISTINCT f2.followee_id
FROM follows f1
JOIN follows f2 ON f1.followee_id = f2.follower_id
WHERE f1.follower_id = :me
AND f2.followee_id != :me
AND NOT EXISTS (SELECT 1 FROM follows WHERE follower_id = :me AND followee_id = f2.followee_id)
LIMIT 20;
For large graphs — pre-calculate via worker, result in Redis.
Timeline
Basic system (follow/unfollow, counters, list) — 1-2 days. With private accounts, notifications, recommendations — 3-5 days. Cost calculated individually.







