A personalized "For You" feed for the AT Protocol, built on the standard feed generator interface. Any client can subscribe to it by its feed AT-URI — there is no client-side code.
A collaborative filter over the network's likes:
- Seed — the viewer's recent likes, each weighted (recent likes are down-weighted to reduce over-reactivity).
- Curators — other users who liked those same posts. A curator's weight reflects how many of the viewer's seed posts they also liked.
- Candidates — those curators' other recent likes. A post's score is a smoothed count of the
independent paths reaching it (
paths^smoothing), normalized by the curators' and seed items' degrees. - Finalize — exponential time-decay (half-life), popularity penalty (
/ likeCount^penalty), a freshness cap, adult-label and reply filtering, per-author diversification, and exclusion of posts the viewer has already liked or seen.
Anonymous viewers and brand-new accounts get a cold-start popularity feed (most-liked recent posts).
For an authenticated but likeless account, that feed is biased toward the languages in the request's
Accept-Language header (forwarded by the AppView) — the only per-viewer taste signal available before
the account has liked anything. Posts declaring a non-matching language are dropped; posts that declare
no language always pass, so the feed narrows toward the viewer's languages without starving. Anonymous
viewers share one cached list, so they stay global (a per-request header there would poison the shared
list). Every ranking parameter is env-overridable — see .env.example.
A personalized list shorter than FEEDGEN_MIN_FEED_SIZE (default 200) is filled up to it with that
same cold-start feed, personalized posts first. An account that has only just onboarded, with a
handful of likes, gets its few personalized posts and then something to scroll into, rather than a
feed that ends after one to five posts. The follows feed is never filled: it would show strangers.
Jetstream (app.bsky.feed.like) ──► ingester ──► Postgres (likes, post_meta)
│
client ──► AppView ──► getFeedSkeleton (viewer DID from JWT)
│ ranker → finalize → Redis-cached list
▼
post URIs ──► client hydrates via getPosts
- Ingestion (src/subscription.ts, src/jetstream.ts) —
consumes the like stream from Jetstream into the
likesedge table; prunes pastFEEDGEN_RETENTION_HOURS. The cursor is persisted, so restarts resume where they left off. - Ranking (src/ranker/) — the seed is read from Postgres; the heavy curator and
candidate work runs against an in-memory like graph (src/graph/) for low-latency
responses.
finalize.tsapplies decay/filters;hydrate.tsfetches post metadata from the public AppView (cached inpost_meta). - Serving (src/algos/for-you.ts, src/methods/) — reads the viewer DID from the signed feed-generator JWT, caches the ranked list per viewer in Redis, and serves unseen posts (see Interactions below).
Two backfills make a viewer's feed good on their first load instead of waiting for the firehose to accumulate:
- Per-viewer seed — the viewer's own like history is imported via
com.atproto.repo.listRecords, so their seed is complete from the start. - Seed co-likers — in the background, the historical likers of the viewer's recent seed posts are
imported from the AppView (
app.bsky.feed.getLikes) to densify the co-liker graph beyond what live ingestion has seen. It's bounded per viewer (once per backfill TTL), per post (deduped across viewers, skipped once a post is well-covered), and in depth (only likes within the retention window), runs off the request path, and invalidates the viewer's cached lists on completion so the next load reflects the denser graph. It's a cold-start bridge: once live ingestion already spans a full retention window the firehose has every in-window like, so the backfill switches itself off (and back on automatically if you later widen retention).
Both are lazy and self-limiting — they run only for viewers who load the feed, and converge as the graph
fills. For a complete cold-start graph independent of who subscribes, do a one-time network-wide repo
backfill instead (com.atproto.sync.listRepos → per-repo like records).
graph(default) — holds the like graph in RAM (built from Postgres on boot, kept live from the firehose, rebuilt periodically). Responses are typically tens of milliseconds.postgres— computes each request as a single Postgres query. Simpler; slower per request.
The in-memory graph has two interchangeable layouts (FEEDGEN_GRAPH_LAYOUT): csr (typed-array
compressed-sparse-row + arena interners; compact, supports large retention windows) and arrays
(Map-based; simpler). FEEDGEN_GRAPH_WINDOW_HOURS controls how much history is held in RAM, with
one exception: likes on the onboarding interest posts — the posts on the FEEDGEN_PICKER_DID account
that the client likes for each interest a new user picks — are loaded whatever their age, just as the
retention sweep never deletes them. They are a newcomer's only link to anyone else, and the older
ones belong to the established accounts that still like things.
The AppView gives up on a getFeedSkeleton call after about 4.7 seconds and renders the timeout as
"Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." — a
the-feed-is-broken message, shown to a viewer whose feed is merely cold. A first load routinely runs
past that ceiling: the inline import of the viewer's likes or follows is bounded separately from
hydration, and if the ranker comes up empty the cold-start feed hydrates all over again. Each stage
honours its own deadline; nothing capped the sum.
FEEDGEN_REQUEST_BUDGET_MS (default 4s) caps it, counted from when the request arrives — verifying
the viewer's token can cost a DID lookup before any ranking starts, and the AppView's clock is already
running. The budget was first sized against a 10-second ceiling at 7s; in production the AppView hung
up at 4.6–4.7s every time, so that budget never once got its answer back to the client.
On a cache miss the computation is started detached and awaited only for the budget. If it lands
in time it is served normally. If it does not, the computation keeps running and caches its
result, and the request returns an XRPC error
carrying Your feed is still being prepared. Pull to refresh in a few seconds. — which is true,
because the next pull hits the warm cache.
That wait is only for a viewer with no list at all. Every computed list is also kept as a copy
for FEEDGEN_PREVIOUS_LIST_TTL_SECONDS (default 2 hours; 0 turns it off). When the list itself has
expired (FEEDGEN_CACHE_TTL_SECONDS) or an import invalidated it, the request is answered from that
copy at once — re-ordered by what the viewer has seen since, as a refresh is — while the fresh list
computes in the background for the next pull. A personalized media list can take ten seconds and
more to compute, so before this a returning viewer's refresh after the list expired regularly came
back as "still being prepared". Only one computation runs per list: requests that arrive while it
runs share it rather than each starting their own.
That error is deliberately 429. The client picks its headline from what it gets back: a timeout reads as "appears to be offline", and any status it does not recognise reads as "some kind of issue occurred — please let the feed owner know", both of which blame a cold feed and put a View profile button under it. 429 is the one branch that renders as "temporarily unavailable, please try again later", with no blame and no button. The AppView passes any non-500 status through unchanged, so both the status and our message survive the hop, and the message prints beneath the headline.
Clients report interaction events via app.bsky.feed.sendInteractions
(src/methods/send-interactions.ts); the AppView proxies them with the
viewer's signed JWT, and publishing sets acceptsInteractions: true so clients send them. Two things
happen:
interactionSeendrives an unseen-only feed — seen posts are tracked per viewer in Redis and filtered out, so a refresh brings new content.- Positive events (
interactionLike,interactionRepost,requestMore, clickthroughs, …) and the negativerequestLessare recorded with a signed weight in theinteractionstable — a durable reward signal for evaluating and tuning ranking parameters against real engagement. The signal is collected only; it does not yet feed back into ranking.
Every served post carries a feedContext naming the feed and the path that put the post there
(src/algos/feed-context.ts):
fu-vids-lang;src=cf;lang=1
<rkey>;src=<cf|follows|popular>[;lang=<0|1>]
src is the collaborative filter, the follows ranking, or the popularity list (the whole feed for a
viewer with no personalization, or the fill under a thin one). lang says whether the post declares one
of the viewer's languages, and is present only when the request carried an Accept-Language. Clients
hand the context back with every interaction, so all feeds can share one endpoint and still be told
apart:
- reward rows store it in
interactions.feed_context; interaction_countstotals every event per day and context,interactionSeenincluded — the denominator that turns raw likes into likes per view, per feed and per path. A context the service could not have issued is neither stored nor counted.
-- likes and "show less" per thousand views, per feed and path, last 7 days
SELECT feed_context,
sum(n) FILTER (WHERE event LIKE '%#interactionSeen') AS views,
round(1000.0 * coalesce(sum(n) FILTER (WHERE event LIKE '%#interactionLike'), 0)
/ NULLIF(sum(n) FILTER (WHERE event LIKE '%#interactionSeen'), 0), 1) AS likes_per_1k,
round(1000.0 * coalesce(sum(n) FILTER (WHERE event LIKE '%#requestLess'), 0)
/ NULLIF(sum(n) FILTER (WHERE event LIKE '%#interactionSeen'), 0), 1) AS less_per_1k
FROM interaction_counts
WHERE day >= to_char(now() - interval '7 days', 'YYYY-MM-DD')
GROUP BY feed_context ORDER BY feed_context;Content-typed variants (images, video) share the one in-memory graph — only a final content filter
differs, so additional feeds add negligible memory. Set FEEDGEN_IMAGE_FEED_RKEY /
FEEDGEN_VIDEO_FEED_RKEY to the rkeys you publish for them.
Because media is a fraction of all posts, content feeds over-generate candidates
(maxCandidates × FEEDGEN_MEDIA_CANDIDATE_MULTIPLIER) before applying the media filter, so a photo or
video feed isn't starved by a content-blind candidate cap.
FEEDGEN_VIDEO_LANG_FEED_RKEY publishes the video feed a second time, ordered by the viewer's
languages: the Accept-Language the AppView forwards from the client's content-language setting.
Posts that declare one of those languages come first, then everything else — other languages and posts
that declare none — each tier in score order with the usual author spacing, and the per-author cap
counted across both. Nothing is dropped for its language: for a small language the second tier is most
of the feed, and the rest of the viewer's own personalized list is better filler than strangers'
popular posts. A list that is still short is filled from the popularity list, ordered the same way.
Anonymous requests share one cached list, so their languages are ignored; a viewer whose client sends none gets plain score order. The original feeds are unchanged — their personalized path stays language-blind and their cold-start list stays filtered to the viewer's languages.
It is a separate feed so it can be compared with the language-blind one before replacing it: each logs
langs=… → N personalized (M in-language) + K popular per computed list, and interaction_counts
(see Interactions) gives likes per view for both.
An optional second algorithm on the same service: the most engaging recent posts from the accounts
you follow. Set FEEDGEN_FOLLOWS_FEED_RKEY to the rkey you publish for it; leave it unset and none
of the machinery below is built.
It is deliberately the inverse of the collaborative filter. The CF feed works out who shares your
taste and divides by popularity to surface niche posts; the follows feed takes the audience as given
and lets raw engagement win — the traditional home-feed shape. Both share the same back half
(ranker/finalize.ts), so they get identical time-decay, freshness, adult/reply filtering and
per-author diversification.
follow list (crawled from the viewer's PDS, once per TTL)
│
▼
author index ──► posts those accounts wrote (and optionally reposted) in the last ~48h,
│ scored as likes + repostWeight × reposts
▼
finalize ──► decay · freshness · adult/reply · diversify ──► ranked URIs
The two structures answer different questions and are sized accordingly:
like graph (graph/csr-like-graph.ts) |
author index (graph/recent-author-index.ts) |
|
|---|---|---|
| Question | "who else liked the posts I liked" | "what did these authors post, and did it land" |
| Needs | every liker's identity, 30-day window | a count per post, ~48h window |
| Cost | ~500M edges, tens of GB | ~5.6M posts, ~0.5 GB |
Dropping liker identity and shrinking the window is what makes the follows feed cheap to add here —
one to two orders of magnitude below the graph, on the same box. Measured on production at a 48h
window: 36.2M likes fold into 5.64M posts across 756k authors, seeded in 212s. Most of that half
gigabyte is interned post URIs, so the footprint scales with FEEDGEN_FOLLOWS_WINDOW_HOURS (the
lever to pull if memory is tight), and a compaction transiently doubles the surviving set while it
rebuilds around it.
It is kept structurally separate rather than bolted onto the CSR build so that the ranker, the index
and the follows table lift out as a unit if it ever needs its own process.
The index is filled once from Postgres at boot (hours of likes, not weeks — so this feed comes up long before a full graph build would finish), then maintained live from the firehose. It has no rebuild; a periodic compaction drops aged-out posts and reclaims their memory.
Reposts are the second engagement signal, ingested on their own Jetstream subscription with their
own cursor — not as a second collection on the like socket. That isolation is deliberate: likes are
the collaborative filter's entire input, and nothing belonging to this feed should be able to stall
it. Set FEEDGEN_FOLLOWS_REPOST_WEIGHT=0 and the subscription is never opened at all.
They are used two ways:
-
As engagement (on by default): a post's score is
likes + repostWeight × reposts, so a repost — scarcer and more deliberate than a like — counts for more. -
As content (
FEEDGEN_FOLLOWS_INCLUDE_REPOSTS, on by default): a post reposted by someone you follow enters the feed even when its author is a stranger — the traditional home-feed behaviour. The skeleton carries askeletonReasonRepost, so clients render "Reposted by …" rather than showing a stranger with no explanation.The attribution travels inside the cached ranked list, which is a flat
string[]paged by offset (seealgos/feed-entry.ts); at-URIs cannot contain a tab, so that separates the two. Entries cached before this existed are bare post URIs and still decode, so no cache invalidation is needed on deploy. The repost record's URI is resolved once per finished list rather than held in the index — tens of bytes per edge across millions of edges, to answer a question only the handful of posts that survive ranking ever ask. An amplified post whose repost record has since been swept is dropped rather than served bare: an unattributed stranger is exactly what the reason exists to prevent.
reposts rows are kept only twice the candidate window (not the 90-day like retention), since
nothing reads them beyond the index seed.
A post enters the index only by being liked or reposted, so a followed account's post with no engagement at all anywhere on the network can never surface. For "bubble the most engaging up" that is the definition of the feed — but it does mean this is not a complete algorithmic-timeline replacement.
The follows feed shares the likes table, post_meta, finalize() and the Redis ranked-list cache
with the CF feed, and nothing else. Everything it adds is either conditional on
FEEDGEN_FOLLOWS_FEED_RKEY being set (the author index, the repost subscription, the follows and
reposts sweeps) or inert without it (authorIndex?. no-ops on the like ingest path). Cache
invalidation is scoped by ranker, so a follow-list sync never drops a viewer's CF list and a like
backfill never drops their follows list.
The one measurable effect on the CF feed is boot ordering: the author index seeds before the like
graph builds, because it scans hours where the graph scans weeks. That delays the graph's first build
by the seed's duration (minutes against its own tens of minutes), in exchange for the follows feed
coming up almost immediately rather than waiting the whole build out. Both feeds serve the cold-start
popularity list until their structure is ready. Swap the two blocks in FeedGenerator.start() to
reverse the trade.
That list is kept in Redis (foryou:popularity:<content>, 6 hours) every time it is counted, and a
restarted process starts from it: counting it again is a GROUP BY over every like in the freshness
window, which on a freshly started Postgres, beside the graph's build, ran for over eight minutes —
and while the graph is building, every viewer's feed waits on it. A kept list older than
FEEDGEN_POPULARITY_CACHE_TTL_SECONDS is served and counted again in the background.
Follow edges are not ingested from the firehose — that would carry network-wide follow traffic for
the handful of viewers who use the feed. Each viewer's list is crawled from their own PDS
(com.atproto.repo.listRecords, public, no auth) on their first request and re-crawled once per
FEEDGEN_FOLLOWS_SYNC_TTL_SECONDS, in the same two-stage inline/background shape as the like
backfill. The full background pass is authoritative, so unfollows disappear.
npm install
cp .env.example .env # then edit (publisher DID, hostname)
docker-compose up -d # local Postgres + Redis
npm start # migrates, builds the graph, starts the serverThe schema migrates on start. The graph builds in the background; until it's ready, requests are served the cold-start popularity feed.
- Serve the app over HTTPS on port 443 at
FEEDGEN_HOSTNAME(the did:web document is exposed at/.well-known/did.json). - Publish the feed record(s):
npm run publishFeed # handle + app password, recordName matching FEEDGEN_FEED_SHORTNAME
Once published, the feed appears in any client's custom-feed search and can be saved or pinned.
All configuration is via environment variables — see .env.example for the full list with descriptions (storage, ingestion, identity/publishing, ranker engine, and ranking parameters).
The ranking algorithm is foryou.club, created by spacecowboy — a trust-based collaborative-filter recommender. This feed is an independent implementation of the likes-only, Bluesky-specific case of it (as in spacecowboy's foryou.club feed): seed from the viewer's recent likes → co-likers → their recent likes, with path-count smoothing, a popularity penalty, a recency half-life, and co-rater decay.
Thanks also to Graze, whose Rust implementation of the LinkLonk algorithm for Bluesky feeds was a helpful reference.
What this implementation focuses on is fitting the graph in RAM. It holds the entire like graph in
memory as a compressed-sparse-row (CSR) layout of typed arrays with an arena-based string interner —
DIDs and URIs are mapped to integer IDs in a chunked byte arena + an open-addressed hash, rather than
JavaScript Maps/objects. That's ~2.5–3× leaner than the same graph built with ordinary JS Maps (the
fallback arrays layout), so the full 90-day like graph fits in ~47 GB and the traversal runs
in-process (tens of milliseconds) on a single commodity server. (Graze takes a different tack — the
graph lives in Redis as day-tranched sorted sets, shared across feeds; this keeps it in-process for
single-feed traversal latency.)
MIT. This project builds on bluesky-social/feed-generator (MIT) — see LICENSE.