API Design
In one line: two calls, and the difference between them is the design. One is invoked by the system, the other by the user, and everything about their requirements diverges.
Generate the newsfeed
generateNewsfeed(user_id)
This API accepts a user ID and retrieves the user's friends and followers. It then composes a newsfeed containing ranked candidate posts. Because this API is invoked by internal components, it can be executed asynchronously to precompute user feeds. The precomputed feeds are stored in persistent storage and cached for fast retrieval.
An API with no caller in the client — and that is the point
generateNewsfeed is never called by a phone. The design is explicit: "invoked by internal components."
That makes it a job, not an endpoint, and its requirements are the inverse of a user-facing call:
generateNewsfeed | An ordinary API | |
|---|---|---|
| Caller | Internal — a fan-out worker or scheduler | The client |
| Latency budget | None | Strict |
| Failure handling | Retry later; nobody is waiting | Return an error |
| Invocation | Triggered by a post, or by a cold read | Per user action |
| Idempotent? | Must be — retries will happen | Should be |
That last row matters and the signature does not address it. If the generation job runs twice for the same user, the feed must not end up duplicated. Since it regenerates the feed rather than appending to it, that holds naturally — a job that computes a value from scratch is idempotent for free, unlike one that mutates incrementally.
Putting a job behind an API signature is a modelling choice worth questioning. It reads as though a client might call it, and clients must not — an exposed generateNewsfeed would be a trivial denial-of-service vector, since it is the single most expensive operation in the system.
One parameter, and what it does not include
user_id alone. Which tells you the generation service reads everything else from storage: the follow graph, the candidate posts, the user's history for ranking.
That is right — passing candidates in would couple the caller to the ranking logic — but it means the parameter list hides a real question: how far back does generation look?
A feed generated at 9 a.m. and again at 9 p.m. should not reproduce the same posts. So generation needs either a cursor (everything since the last generation) or a window (the last N hours), and neither is in the signature.
Lesson 8's step 3 says the service retrieves "the latest, most popular, and relevant posts" — so a recency window exists implicitly. Making it a parameter would let the same job serve both incremental updates (new posts since last run) and full rebuilds (a lapsed user returning), which Lesson 4 established are two different paths through the same code.
Get the newsfeed
getNewsfeed(user_id, count)
| Parameter | Description |
|---|---|
user_id | The user for whom the system will fetch the newsfeed |
count | The number of feed items retrieved per request |
The getNewsfeed API returns a JSON object containing a list of posts.
No cursor — so this API cannot paginate correctly
count says how many. Nothing says from where.
The Twitter chapter established why that fails: in a feed that is constantly growing, an implicit "from the top" or an offset breaks as soon as new items arrive between requests. The user sees duplicates, or misses items, or both.
And Lesson 8's own description requires pagination: "whenever Alice reaches the end of her timeline, the next top N posts are fetched to her screen." That is infinite scroll — which is exactly the case that needs a stable cursor.
The fix is the one that building block used:
getNewsfeed(user_id, count) <- as specified: unstable getNewsfeed(user_id, count, cursor) <- stable
where the cursor encodes a position in the generated feed, not in time.
That last distinction matters more here than it did for Twitter. Twitter's timeline was chronological, so a time-sortable tweet ID was a natural cursor. A ranked feed has no natural sort key — position 50 is wherever the ranking put it, and re-ranking between requests would reshuffle everything.
Which forces a specific design: the generated feed must be frozen for the duration of a scroll session. You rank once, store the ordered list, and paginate through that — regenerating mid-scroll would make pagination incoherent.
A ranked feed must be materialized before it can be paginated. That is a real constraint the API does not express, and it is one of the strongest arguments for precomputation over pure on-demand generation.
Two calls, and the architecture in miniature
Step back and the pair encodes the whole design:
generateNewsfeed(user_id) ASYNCHRONOUS, expensive, internal
|
v
[newsfeed cache] the materialized, ranked list
|
v
getNewsfeed(user_id, count) SYNCHRONOUS, cheap, user-facing
The cache between them is what decouples the latency budgets, exactly as Lesson 5 described for the generation/publishing split.
And it explains why Lesson 2's requirement — "complex feed generation can run asynchronously, but the read path must remain fast" — is satisfiable at all. The two APIs exist so that the expensive one never appears in a user's latency budget.
Compare a design with one call, getNewsfeed, that generated on demand. It would be simpler and it would put graph traversal, candidate fetching, and ML ranking inside a 2-second window, 58,000 times a second. Every materialized-view architecture is fundamentally this pair of operations — one to build, one to read — and naming it that way is more useful than naming the services.
Key takeaway
generateNewsfeed is a job wearing an API signature — internal, asynchronous, with no latency budget and an implicit requirement to be idempotent, which it gets free by recomputing rather than appending. It should never be client-callable, since it is the most expensive operation in the system. getNewsfeed lacks a cursor, so it cannot paginate a growing feed — and the fix is harder here than for a chronological timeline because a ranked feed has no natural sort key, which forces the feed to be frozen for the duration of a scroll session. Together the two calls are the materialized-view pattern: one operation builds, one reads, and the cache between them is what keeps the expensive half out of the user's latency budget.
Next: the storage schema, including a graph modelled in relational tables.