Go Guide

System design · Lesson 60 of 62 · about 90 min

Design a social news feed

Design the home timeline of Twitter, Instagram or Facebook: fan-out on write versus fan-out on read, the hybrid approach for celebrities, merging timelines, cursor pagination and caching, then build a feed service in Go with a page to follow people and scroll.

On this page · 9 sections
  1. 011. Requirements
  2. 022. Estimates
  3. 033. Push, pull, or both
  4. 044. High-level design
  5. 055. Deep dives
  6. 06Build it: a hybrid feed service in Go
  7. 07Summary
  8. 08Check your understanding
  9. 09Practice in Go

1. Requirements#

Functional: users publish short posts and follow other users; the home timeline shows posts from followed accounts (and your own), newest first, with infinite scroll.

Non-functional: the timeline loads in under 200 ms; reads vastly outnumber writes (roughly 100 to 1); a new post shows up for followers within a few seconds; it works for accounts with tens of millions of followers.

2. Estimates#

200 million daily users each open the feed 10 times a day: 2 billion timeline reads a day, around 23,000 per second on average. They publish 20 million posts a day, about 230 per second. The average account has 200 followers, but the largest have 100 million or more.

With fan-out on write, 230 posts per second × 200 followers ≈ 46,000 timeline inserts per second. A timeline cache of the latest 800 post IDs per user, 8 bytes each, is about 6.4 KB per user and 1.3 TB for 200 million users. That is a lot of RAM, but it can be sharded across a Redis cluster.

3. Push, pull, or both#

fanout.go
Go
package main
 
import "fmt"
 
func main() {
	type account struct {
		name             string
		followers, posts int // posts per day
	}
	accounts := []account{
		{"typical user", 200, 3},
		{"popular creator", 50_000, 10},
		{"celebrity", 60_000_000, 5},
	}
	for _, a := range accounts {
		writes := a.followers * a.posts
		fmt.Printf("%-16s push: %11d timeline writes/day   pull: %d posts read from it per follower visit\n",
			a.name, writes, min(a.posts, 20))
	}
}
Output
typical user     push:         600 timeline writes/day   pull: 3 posts read from it per follower visit
popular creator  push:      500000 timeline writes/day   pull: 10 posts read from it per follower visit
celebrity        push:   300000000 timeline writes/day   pull: 5 posts read from it per follower visit
Fan-out on write (push)Fan-out on read (pull)
when a post is createdinsert its ID into every follower's timelinestore it once
when a timeline is readone lookupquery every followed account, merge
good forordinary accounts, read-heavy appscelebrities, inactive followers
problemhuge accounts cause millions of writesreads get slower the more you follow

Hybrid: push posts from accounts below a follower threshold (say 10,000) into followers' timelines. Posts from accounts above it are not pushed; when you read, the service fetches the recent posts of the few celebrities you follow and merges them into your precomputed timeline.

4. High-level design#

Drawing diagram…

Timelines hold IDs only. Hydrating (turning IDs into full posts with author names and like counts) happens at read time from caches, so an edited post or a changed avatar shows the new version everywhere.

5. Deep dives#

  • Merging. The timeline is a merge of sorted lists: the precomputed one plus one per followed celebrity. A min-heap merges k sorted lists in O(n log k); when the lists are short, collecting and sorting is simpler and just as fast.
  • Cursor pagination. "Give me 20 posts older than ID 9123" is stable while new posts arrive. Offsets are not: five new posts push five old ones from page 1 onto page 2, and the reader sees them again.
  • Follow and unfollow. Following someone backfills their recent posts into your timeline so it is not empty until they post again; unfollowing removes them (or filters them out at read time).
  • Inactive users. Pushing to people who have not opened the app in months is wasted work. Skip their timelines and rebuild them with a pull the next time they log in.
  • Ranking. Most feeds are no longer purely chronological. The same pipeline produces candidates, then a ranking model orders them. Chronological order stays the fallback and the debugging baseline.

Build it: a hybrid feed service in Go#

You will build a feed package with a follow graph, time-ordered post IDs, fan-out on write with a capped timeline per user, pull-on-read for celebrities, follow backfill, and cursor pagination. Then an HTTP API and a page where you can switch between users, follow people, post, and scroll. A counter of timeline writes shows the cost of pushing.

feed/
├── go.mod
├── main.go                     HTTP API + demo data
├── main_test.go
├── internal/feed/feed.go       graph, posts, timelines
├── internal/feed/feed_test.go
└── web/index.html              follow, post, scroll

Step 1: module

Shell
mkdir feed && cd feed && go mod init feed
mkdir -p internal/feed web
go.mod
module feed
 
go 1.24

Step 2: the store

Post IDs come from one increasing counter, standing in for Snowflake IDs: bigger means newer, so every list is sorted by time simply by being sorted by ID. An account becomes a celebrity the first time it posts with at least threshold followers, and stays one, so its posts are never half pushed and half pulled.

internal/feed/feed.go
Go
// Package feed implements home timelines with hybrid fan-out:
// push for ordinary accounts, pull for celebrities.
package feed
 
import (
	"errors"
	"math"
	"regexp"
	"slices"
	"strings"
	"sync"
	"time"
	"unicode/utf8"
)
 
// Post is one published post. IDs increase over time.
type Post struct {
	ID     int64     `json:"id"`
	Author string    `json:"author"`
	Text   string    `json:"text"`
	At     time.Time `json:"at"`
}
 
// UserInfo describes an account as seen by a viewer.
type UserInfo struct {
	Name      string `json:"name"`
	Followers int    `json:"followers"`
	Celebrity bool   `json:"celebrity"`
	Followed  bool   `json:"followed"` // does the viewer follow this account?
}
 
var (
	ErrInvalidName = errors.New("names are 1 to 20 characters: a-z, 0-9 or _")
	ErrInvalidText = errors.New("posts are 1 to 280 characters")
	ErrSelfFollow  = errors.New("you cannot follow yourself")
)
 
var validName = regexp.MustCompile(`^[a-z0-9_]{1,20}$`)
 
// Store keeps everything in memory behind one lock.
type Store struct {
	mu        sync.RWMutex
	threshold int // followers at which an account stops being pushed
	inboxCap  int // timeline entries kept per user
	now       func() time.Time
	nextID    int64
	users     map[string]bool
	posts     map[int64]Post
	byAuthor  map[string][]int64 // ascending
	followers map[string]map[string]bool
	following map[string]map[string]bool
	celebs    map[string]bool
	inbox     map[string][]int64 // precomputed timelines, ascending, at most inboxCap
	writes    int                // timeline inserts done by fan-out
}
 
// New creates a store. Accounts with at least threshold followers are pulled, not pushed.
func New(threshold, inboxCap int, now func() time.Time) *Store {
	if now == nil {
		now = time.Now
	}
	return &Store{
		threshold: threshold, inboxCap: inboxCap, now: now,
		users: map[string]bool{}, posts: map[int64]Post{}, byAuthor: map[string][]int64{},
		followers: map[string]map[string]bool{}, following: map[string]map[string]bool{},
		celebs: map[string]bool{}, inbox: map[string][]int64{},
	}
}
 
func (s *Store) ensureLocked(name string) {
	if !s.users[name] {
		s.users[name] = true
		s.followers[name] = map[string]bool{}
		s.following[name] = map[string]bool{}
	}
}
 
// AddUser registers an account (posting or following also registers it).
func (s *Store) AddUser(name string) error {
	if !validName.MatchString(name) {
		return ErrInvalidName
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.ensureLocked(name)
	return nil
}
 
// Publish stores a post and, unless the author is a celebrity, pushes its ID to every follower.
func (s *Store) Publish(author, text string) (Post, error) {
	if !validName.MatchString(author) {
		return Post{}, ErrInvalidName
	}
	if n := utf8.RuneCountInString(text); n == 0 || n > 280 {
		return Post{}, ErrInvalidText
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.ensureLocked(author)
	s.nextID++
	p := Post{ID: s.nextID, Author: author, Text: text, At: s.now().UTC()}
	s.posts[p.ID] = p
	s.byAuthor[author] = append(s.byAuthor[author], p.ID)
	if len(s.followers[author]) >= s.threshold {
		s.celebs[author] = true
	}
	if !s.celebs[author] {
		for f := range s.followers[author] {
			s.inbox[f] = s.capped(append(s.inbox[f], p.ID))
			s.writes++
		}
	}
	return p, nil
}
 
func (s *Store) capped(ids []int64) []int64 {
	if extra := len(ids) - s.inboxCap; extra > 0 {
		return slices.Delete(ids, 0, extra)
	}
	return ids
}
 
// Follow adds an edge and backfills the followee's recent posts (unless they are pulled anyway).
func (s *Store) Follow(follower, followee string) error {
	if !validName.MatchString(follower) || !validName.MatchString(followee) {
		return ErrInvalidName
	}
	if follower == followee {
		return ErrSelfFollow
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	s.ensureLocked(follower)
	s.ensureLocked(followee)
	if s.following[follower][followee] {
		return nil
	}
	s.following[follower][followee] = true
	s.followers[followee][follower] = true
	if !s.celebs[followee] {
		recent := s.byAuthor[followee]
		recent = recent[max(0, len(recent)-s.inboxCap):]
		merged := append(slices.Clone(s.inbox[follower]), recent...)
		slices.Sort(merged)
		s.inbox[follower] = s.capped(slices.Compact(merged))
	}
	return nil
}
 
// Unfollow removes the edge and the followee's posts from the follower's timeline.
func (s *Store) Unfollow(follower, followee string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !s.following[follower][followee] {
		return
	}
	delete(s.following[follower], followee)
	delete(s.followers[followee], follower)
	s.inbox[follower] = slices.DeleteFunc(s.inbox[follower], func(id int64) bool { return s.posts[id].Author == followee })
}
 
// Timeline returns up to limit posts with IDs below before (0 means from the newest), newest
// first, plus the cursor for the next page (0 when there is nothing more).
func (s *Store) Timeline(user string, before int64, limit int) ([]Post, int64) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if before <= 0 {
		before = math.MaxInt64
	}
	var ids []int64
	take := func(list []int64) { // list is ascending: take the newest limit IDs below the cursor
		i, _ := slices.BinarySearch(list, before)
		ids = append(ids, list[max(0, i-limit):i]...)
	}
	take(s.inbox[user])    // pushed: ordinary accounts
	take(s.byAuthor[user]) // your own posts
	for f := range s.following[user] {
		if s.celebs[f] {
			take(s.byAuthor[f]) // pulled: celebrities
		}
	}
	slices.Sort(ids)
	ids = slices.Compact(ids)
	slices.Reverse(ids)
	ids = ids[:min(limit, len(ids))]
 
	posts := make([]Post, len(ids))
	for i, id := range ids {
		posts[i] = s.posts[id]
	}
	var next int64
	if len(posts) == limit {
		next = posts[len(posts)-1].ID
	}
	return posts, next
}
 
// Users lists every account, sorted by name, from the viewer's point of view.
func (s *Store) Users(viewer string) []UserInfo {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]UserInfo, 0, len(s.users))
	for name := range s.users {
		out = append(out, UserInfo{Name: name, Followers: len(s.followers[name]), Celebrity: s.celebs[name], Followed: s.following[viewer][name]})
	}
	slices.SortFunc(out, func(a, b UserInfo) int { return strings.Compare(a.Name, b.Name) })
	return out
}
 
// Writes reports how many timeline inserts fan-out has done so far.
func (s *Store) Writes() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.writes
}
internal/feed/feed_test.go
Go
package feed
 
import (
	"errors"
	"fmt"
	"slices"
	"strings"
	"testing"
	"time"
)
 
func texts(ps []Post) []string {
	var out []string
	for _, p := range ps {
		out = append(out, p.Text)
	}
	return out
}
 
func newStore(threshold int) *Store {
	return New(threshold, 800, func() time.Time { return time.Unix(0, 0) })
}
 
func TestFanOutOnWrite(t *testing.T) {
	s := newStore(100)
	s.Follow("ann", "bob")
	s.Follow("cat", "bob")
	for _, txt := range []string{"b1", "b2", "b3"} {
		s.Publish("bob", txt)
	}
	s.Publish("ann", "a1")
	posts, next := s.Timeline("ann", 0, 10)
	if got := texts(posts); !slices.Equal(got, []string{"a1", "b3", "b2", "b1"}) {
		t.Errorf("ann's timeline = %v", got)
	}
	if next != 0 {
		t.Errorf("next = %d, want 0 (no more)", next)
	}
	if s.Writes() != 6 {
		t.Errorf("writes = %d, want 6 (3 posts x 2 followers)", s.Writes())
	}
}
 
func TestBackfillAndUnfollow(t *testing.T) {
	s := newStore(100)
	s.Publish("bob", "old post")
	s.Follow("ann", "bob")
	if got := texts(must(s.Timeline("ann", 0, 10))); !slices.Equal(got, []string{"old post"}) {
		t.Errorf("after follow = %v, want the backfilled post", got)
	}
	s.Unfollow("ann", "bob")
	if got := must(s.Timeline("ann", 0, 10)); len(got) != 0 {
		t.Errorf("after unfollow = %v", texts(got))
	}
}
 
func TestCelebritiesArePulled(t *testing.T) {
	s := newStore(2)
	s.Follow("ann", "star")
	s.Follow("bob", "star")
	s.Follow("ann", "bob")
	s.Publish("bob", "b1")
	s.Publish("star", "s1") // 2 followers: becomes a celebrity, nothing pushed
	s.Publish("bob", "b2")
	if s.Writes() != 2 {
		t.Errorf("writes = %d, want 2 (only bob's posts to ann)", s.Writes())
	}
	if got := texts(must(s.Timeline("ann", 0, 10))); !slices.Equal(got, []string{"b2", "s1", "b1"}) {
		t.Errorf("merged timeline = %v", got)
	}
	s.Unfollow("bob", "star") // dropping below the threshold does not flip it back
	s.Publish("star", "s2")
	if s.Writes() != 2 {
		t.Errorf("celebrity status should be sticky; writes = %d", s.Writes())
	}
}
 
func TestCursorPagination(t *testing.T) {
	s := newStore(100)
	s.Follow("ann", "bob")
	for i := 1; i <= 25; i++ {
		s.Publish("bob", fmt.Sprint(i))
	}
	var all []string
	var cursor int64
	pages := 0
	for {
		posts, next := s.Timeline("ann", cursor, 10)
		all = append(all, texts(posts)...)
		pages++
		if next == 0 {
			break
		}
		s.Publish("bob", "new") // new posts arriving must not shift the pages we still read
		cursor = next
	}
	if pages != 3 || len(all) != 25 || all[0] != "25" || all[24] != "1" {
		t.Errorf("%d pages, %d posts: %v", pages, len(all), all)
	}
}
 
func TestInboxCapAndValidation(t *testing.T) {
	s := New(100, 3, nil)
	s.Follow("ann", "bob")
	for i := range 5 {
		s.Publish("bob", fmt.Sprint(i))
	}
	if got := texts(must(s.Timeline("ann", 0, 10))); !slices.Equal(got, []string{"4", "3", "2"}) {
		t.Errorf("capped timeline = %v", got)
	}
	if err := s.Follow("ann", "ann"); !errors.Is(err, ErrSelfFollow) {
		t.Errorf("self follow: %v", err)
	}
	if err := s.Follow("Ann!", "bob"); !errors.Is(err, ErrInvalidName) {
		t.Errorf("bad name: %v", err)
	}
	for _, txt := range []string{"", strings.Repeat("x", 281)} {
		if _, err := s.Publish("ann", txt); !errors.Is(err, ErrInvalidText) {
			t.Errorf("text of %d chars: %v", len(txt), err)
		}
	}
}
 
func must(ps []Post, _ int64) []Post { return ps }

Step 3: the API

main.go
Go
package main
 
import (
	_ "embed"
	"encoding/json"
	"log/slog"
	"net/http"
	"os"
	"strconv"
	"time"
 
	"feed/internal/feed"
)
 
//go:embed web/index.html
var indexHTML []byte
 
func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.NewEncoder(w).Encode(v)
}
 
func newServer(s *feed.Store) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		w.Write(indexHTML)
	})
	mux.HandleFunc("GET /api/users", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]any{"users": s.Users(r.URL.Query().Get("viewer")), "writes": s.Writes()})
	})
	mux.HandleFunc("GET /api/timeline", func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
		limit, err := strconv.Atoi(q.Get("limit"))
		if err != nil || limit < 1 || limit > 100 {
			limit = 20
		}
		posts, next := s.Timeline(q.Get("user"), before, limit)
		writeJSON(w, http.StatusOK, map[string]any{"posts": posts, "next": next})
	})
	mux.HandleFunc("POST /api/posts", func(w http.ResponseWriter, r *http.Request) {
		var in struct{ Author, Text string }
		if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<14)).Decode(&in) != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
			return
		}
		p, err := s.Publish(in.Author, in.Text)
		if err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
			return
		}
		writeJSON(w, http.StatusCreated, p)
	})
	mux.HandleFunc("POST /api/follow", func(w http.ResponseWriter, r *http.Request) {
		var in struct {
			Follower, Followee string
			Unfollow           bool
		}
		if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<12)).Decode(&in) != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
			return
		}
		if in.Unfollow {
			s.Unfollow(in.Follower, in.Followee)
		} else if err := s.Follow(in.Follower, in.Followee); err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})
	return mux
}
 
// seed creates a small network: "nasa" has enough followers to be a celebrity.
func seed(s *feed.Store) {
	for _, u := range []string{"ann", "bob", "cat", "dan"} {
		s.Follow(u, "nasa")
	}
	s.Follow("ann", "bob")
	s.Follow("ann", "cat")
	s.Follow("bob", "ann")
	s.Publish("bob", "Just finished the consistent hashing lesson.")
	s.Publish("nasa", "A new image of the Pillars of Creation, in infrared.")
	s.Publish("cat", "Go 1.24 iterators are great for paginated APIs.")
	s.Publish("ann", "Hello, feed!")
}
 
func main() {
	s := feed.New(4, 800, nil) // 4 followers makes a celebrity, so the demo shows both paths
	seed(s)
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	slog.Info("listening", "port", port)
	srv := &http.Server{Addr: ":" + port, Handler: newServer(s), ReadHeaderTimeout: 5 * time.Second}
	if err := srv.ListenAndServe(); err != nil {
		slog.Error("server", "err", err)
		os.Exit(1)
	}
}
main_test.go
Go
package main
 
import (
	"encoding/json"
	"net/http/httptest"
	"strings"
	"testing"
 
	"feed/internal/feed"
)
 
func TestAPI(t *testing.T) {
	s := feed.New(4, 800, nil)
	seed(s)
	h := newServer(s)
	call := func(method, url, body string) *httptest.ResponseRecorder {
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, httptest.NewRequest(method, url, strings.NewReader(body)))
		return rec
	}
 
	var page struct {
		Posts []feed.Post
		Next  int64
	}
	json.NewDecoder(call("GET", "/api/timeline?user=ann", "").Body).Decode(&page)
	var authors []string
	for _, p := range page.Posts {
		authors = append(authors, p.Author)
	}
	if got := strings.Join(authors, ","); got != "ann,cat,nasa,bob" {
		t.Errorf("ann's timeline authors = %s", got)
	}
 
	if rec := call("POST", "/api/follow", `{"follower":"dan","followee":"dan"}`); rec.Code != 400 {
		t.Errorf("self follow: %d", rec.Code)
	}
	if rec := call("POST", "/api/follow", `{"follower":"dan","followee":"cat"}`); rec.Code != 204 {
		t.Errorf("follow: %d", rec.Code)
	}
	if rec := call("POST", "/api/posts", `{"author":"cat","text":"hi dan"}`); rec.Code != 201 {
		t.Errorf("post: %d", rec.Code)
	}
	json.NewDecoder(call("GET", "/api/timeline?user=dan&limit=1", "").Body).Decode(&page)
	if len(page.Posts) != 1 || page.Posts[0].Text != "hi dan" || page.Next == 0 {
		t.Errorf("dan's first page = %+v", page)
	}
	if rec := call("POST", "/api/posts", `{"author":"cat","text":""}`); rec.Code != 400 {
		t.Errorf("empty post: %d", rec.Code)
	}
}

Step 4: the page

Switch between users, follow and unfollow, post, and load older pages with the cursor. Celebrity accounts are marked with ★, and the counter shows how many timeline writes fan-out has done: post as nasa and it does not move.

web/index.html
HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Feed</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 860px; margin: 2rem auto; padding: 0 1rem; }
    select, input, button { padding: .45rem .7rem; border-radius: 8px; border: 1px solid #999; font: inherit; background: #fff; }
    button { cursor: pointer; }
    #wrap { display: grid; grid-template-columns: 1fr 220px; gap: 1.5rem; }
    .post { border-bottom: 1px solid #ddd; padding: .7rem 0; } .post b { margin-right: .4rem; } .post small { color: #888; font-family: monospace; }
    .user { display: flex; justify-content: space-between; align-items: center; padding: .3rem 0; font-size: 14px; }
    form { display: flex; gap: .5rem; margin: 1rem 0; } form input { flex: 1; }
    #stats { color: #666; font-size: 13px; }
  </style>
</head>
<body>
  <h1>Feed</h1>
  <p>You are <select id="me"></select> <span id="stats"></span></p>
  <div id="wrap">
    <div>
      <form id="compose"><input id="text" maxlength="280" placeholder="What's happening?"><button>Post</button></form>
      <div id="posts"></div>
      <p><button id="more" hidden>Load older</button></p>
    </div>
    <div><b>People</b><div id="people"></div></div>
  </div>
  <script>
    let next = 0;
    const api = (url, body) => fetch(url, body ? { method: "POST", body: JSON.stringify(body) } : undefined);
    async function loadUsers() {
      const data = await (await api(`/api/users?viewer=${me.value}`)).json();
      if (!me.options.length) for (const u of data.users) me.add(new Option(u.name, u.name));
      stats.textContent = `timeline writes so far: ${data.writes}`;
      people.innerHTML = "";
      for (const u of data.users) {
        if (u.name === me.value) continue;
        const row = document.createElement("div");
        row.className = "user";
        row.textContent = `${u.celebrity ? "★ " : ""}${u.name} (${u.followers})`;
        const b = document.createElement("button");
        b.textContent = u.followed ? "Unfollow" : "Follow";
        b.onclick = async () => {
          await api("/api/follow", { follower: me.value, followee: u.name, unfollow: u.followed });
          refresh();
        };
        row.append(b);
        people.append(row);
      }
    }
    async function loadPage(reset) {
      if (reset) { posts.innerHTML = ""; next = 0; }
      const data = await (await api(`/api/timeline?user=${me.value}&limit=5&before=${next}`)).json();
      for (const p of data.posts) {
        const d = document.createElement("div");
        d.className = "post";
        const who = document.createElement("b"); who.textContent = p.author;
        const id = document.createElement("small"); id.textContent = `#${p.id}`;
        d.append(who, id, document.createElement("br"), document.createTextNode(p.text));
        posts.append(d);
      }
      next = data.next;
      more.hidden = !next;
    }
    async function refresh() { await loadUsers(); await loadPage(true); }
    me.onchange = refresh;
    more.onclick = () => loadPage(false);
    compose.onsubmit = async (e) => {
      e.preventDefault();
      if (!text.value.trim()) return;
      await api("/api/posts", { author: me.value, text: text.value });
      text.value = "";
      refresh();
    };
    loadUsers().then(refresh);
  </script>
</body>
</html>

Step 5: run it

Shell
go test -race ./...
go run .
# open http://localhost:8080
curl -s "localhost:8080/api/timeline?user=ann&limit=2"
# {"next":3,"posts":[{"id":4,"author":"ann",...},{"id":3,"author":"cat",...}]}
curl -s "localhost:8080/api/timeline?user=ann&limit=2&before=3"

As ann, post a few times, then switch to bob (who follows ann): the posts are there because they were pushed. Post as nasa and the write counter stays the same, yet every follower sees the post because it is pulled at read time.

Step 6: toward production

  • Keep timelines in Redis lists (LPUSH + LTRIM 0 799) sharded by user, and posts in a database keyed by ID with a cache in front.
  • Run fan-out in workers fed by a queue, so publishing returns at once and a burst of posts does not slow the API.
  • Skip pushes to users inactive for 30+ days and rebuild their timeline with a pull when they return.
  • Hydrate in batches (MGET of posts and authors) and add like counts and "you liked this" at read time.

Summary#

  • Fan-out on write makes reads one lookup but costs one write per follower; fan-out on read is the reverse.
  • The hybrid pushes ordinary accounts and pulls celebrities, merging both at read time.
  • Time-ordered IDs make every list sorted by time and make the cursor a single number.
  • Cursor pagination stays correct while new posts arrive; offsets do not.
  • Timelines store IDs only; posts are hydrated at read time so edits show up everywhere.

Check your understanding

Answer before scrolling back up. If you miss one, reread the matching section, then try again.

  1. Q1.What is fan-out on write?

  2. Q2.Why not use fan-out on write for an account with 50 million followers?

  3. Q3.Why use a cursor ("posts older than ID 9123") instead of page numbers ("page 3")?

  4. Q4.Post IDs here are increasing (like Snowflake IDs). Why does that help the feed?

  5. Q5.In the hybrid model, what happens when you open the app?

Practice in Go

Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.

  • Show hint

    This is the pull model: merge each followee's recent tweets with a heap.

  • Show hint

    The core of fan-out on read: k sorted lists of post IDs, merged with a min-heap.

  • Ranked feedbuild

    Add a Ranked timeline that scores each candidate post by recency, the number of likes, and whether the viewer liked the author before, then sorts by score. Keep the chronological timeline as is and test both.

  • Mute and blockbuild

    Add mute (hide an author's posts without unfollowing) and block (remove both follow edges and hide in both directions). Filtering must happen at read time so un-muting brings posts back.

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.