My Bluesky home feed kept returning me to the same runaway reply chain, the hellthread. It surfaced between the posts I wanted no matter how often I scrolled past it. When I built my own feed generator, I banned that one thread with a WHERE clause. There was no settings toggle or transparency report. Every feed contains choices like this, but outside readers rarely see them. Mine is line 6 of queries.ts:

AND NOT exists((post)-[:ROOT]->(:Post
  {uri:'at://did:plc:wgaezxqi2spqm3mhrb5xvkzi/app.bsky.feed.post/3juzlwllznd24'}))

That URI is the hellthread’s root. Any post whose thread root resolves to it never reaches my feed.

BlueJ exists because developers briefly had that access in April 2023. Bluesky was invite-only, with roughly fifty or sixty thousand accounts. The AT Protocol offered a firehose of every public network event and allowed developers to serve their own feed algorithms to real users, two capabilities Twitter had closed. I was working at Memgraph, a graph database company, and a social network is a graph changing every second. I wanted to make the ranking decisions myself. The feed shipped under the shortname home-plus.

A like can arrive before its post

More choices appear upstream, where the firehose becomes a graph. Events can arrive out of order, so a like may arrive before its post. The stream contains WebSocket-delivered repository commits encoded as CBOR and packed into CAR files. readCar and cborToLexRecord from @atproto/repo unpack them. Each event then becomes a Cypher query against Memgraph. A like, for instance:

MERGE (person:Person {did: $authorDid})
MERGE (post:Post {uri: $postUri})
MERGE (person)-[:LIKE {weight: 1, uri: $uri}]->(post)

The first ones live in the weights: AUTHOR_OF edges carry 0, LIKE carries 1, FOLLOW carries 2. There was no study behind that ratio. A follow is worth two likes because I typed a 2.

Out-of-order arrival is also why everything is MERGE rather than CREATE: MERGE creates a placeholder Post node that gets enriched when the real create shows up. And when a query fails, it lands in a retry queue drained every 5 seconds, ten tries before it’s dropped. The occasional database hiccup doesn’t get a vote on whether the graph remembers what happened.

The 300 posts nobody ranked

The feed is built from three Cypher queries run in parallel:

let queryResults = await parallelQueries(requesterDid, maxNodeId, {
    follow: { query: followQuery, limit: 300 },
    likedByFollow: { query: likedByFollowQuery, limit: 100 },
    community: { query: communityQuery, limit: 100 }
})

Three hundred posts from the people you follow, a hundred posts your follows liked, a hundred from accounts sharing your community_id, clusters computed over the follow graph.

I would have told you one decay formula governed everything here. Rereading the queries while writing this up, that’s wrong. The follow query returns 1 as score and orders by post.indexedAt DESC: the 300-post core of the feed is reverse-chronological, untouched by ranking. Decay only governs the two discovery streams, scored inline:

WITH(ceil(likes) / ceil(1 + (hour_age * hour_age * hour_age * hour_age))) as score, likes, hour_age, post, follow_person

Likes over hour_age⁴, and a fourth power is brutal: the denominator hits 256 at four hours and 331,776 at twenty-four. Nothing stays discovered for long.

All three queries drop anything older than five days. The discovery streams take top-level posts only; the follows stream lets your people’s replies through, with the one hardcoded exception you’ve already seen.

Two better formulas, both dead in the repo

The repository still contains ranking ideas I did not use. query_module/bluej.cpp compiles Paul Graham’s Hacker News gravity formula into a native Memgraph procedure callable as hacker_news(votes, item_hour_age, gravity):

const auto score = 1000000.0 * (votes / pow((item_hour_age + 2), gravity));

The production queries never call it. They also never call the scorer in app/server.js, which decays a post over roughly twenty-four hours on a cosine curve and weights the three sources 10x, 1.5x, and 1x. Both were more principled than an inline hour_age^4. The repo can’t tell me the day I gave up on them; what I remember is that a formula I could see inside the query and tune in place beat an elegant one hidden behind a procedure call. Every nice scoring theory had to survive contact with the live graph; those two didn’t.

One of each, then follows alone

Blending three arrays into one feed is weightedRoundRobin plus a dedup by node id, so a post that’s both written by a follow and liked by your follows appears once. The 300/100/100 limits suggest a three-to-one blend; the loop does something else. It takes one item from each array per round: one follow, one liked, one community post, repeating, until the discovery streams run dry around round 100, after which roughly two hundred follows run out the tail. Discovery concentrates at the top of the feed, and the deeper you scroll, the more it relaxes into plain chronology.

A painterly editorial collage of nodes, relays, follows, and high-volume event streams.
Nodes, relays, follows, and high-volume event streams.

What pull-to-refresh looks like from the server

A refresh and a pagination request arrive looking nearly identical, and users mean opposite things by them: refresh means new posts on top, pagination means hold everything exactly where it was. BlueJ guesses intent from the parameters: a limit of 10 or more with no cursor reads as a genuine refresh, and the feed records the highest node id that user has seen:

if (limit >= 10 && cursor === undefined) {
    didLastSeen[requesterDid] = {
        maxNodeId: maxNodeId,
        timestamp: Date.now()
    }
}

On the next build, everything above that id filters to the top and the rest keeps its order; didLastSeen entries expire after twelve hours. The cursor itself is maxNodeId::requesterDid::position. The DID gets checked against the request’s JWT (a mismatch throws [ERROR] JWT and cursor DID do not match), and position clamps at 600 to keep abuse cheap. None of this is scoring, and it still shapes the top of your screen as much as the decay formula does.

Before I trusted any of it, I needed to see it. Memgraph triggers fire on changed nodes and edges, push through a C++ query module over HTTP to a small Node service, and Socket.io broadcasts to a React front end running react-force-graph in 2D or 3D. Likes arrive as edges snaking toward a post; a new follow tugs two people closer as the force-directed layout finds its equilibrium. Completely impractical for any production purpose, and it did more for my intuition about the network than any query result.

Three feeds shipped in the end: home-plus, a friends-and-community variant, and authors, which does nothing cleverer than post.text CONTAINS "#author", a plain hashtag feed for the writing community.

My hellthread ban applied to every subscriber, and they could not read the WHERE clause from inside Bluesky any more than I can read Twitter’s. Publishing the code helps only the people who open it. For my feed, that may have been nobody but me.

The habit stayed. When I scroll a ranked feed, I wonder where its WHERE clauses are. Mine remain in one short file beside an unused fourth query called topFollowQuery. The ban is still on line 6.

Chris Chabot · June 2023