DEV Community

Cover image for Three things that changed when I switched Bluesky growth to follower-graph sourcing
MORINAGA
MORINAGA

Posted on

Three things that changed when I switched Bluesky growth to follower-graph sourcing

Since April I've been running a follow growth script for the Bluesky accounts attached to three AI-curated directory sites. The original candidate source: search recent posts under a set of hashtags and follow the authors. It worked well enough to get the accounts past 200 followers, but the follow-back rate was lower than I wanted — around 10–12%.

The CEO's direction on July 29: switch to follower-graph sourcing. Instead of searching hashtags for candidate authors, collect the accounts that recently followed us and then follow their followers. Someone who follows a person who found us organically is a much closer audience match than whoever happened to post under #indiehackers in the last 24 hours.

Switching sources looked simple in the config file — one line change. In practice, three things had to change in the implementation that were not obvious from the outside.

Organic seeds come first

The follower-graph source works like this: fetch our most recent 100 followers via app.bsky.graph.getFollowers, use up to 15 of them as seeds, then fetch up to 100 followers of each seed. That gives a candidate pool of roughly 1,500 accounts before quality filtering.

The question is which 15 seeds to use when we have more than 15 recent followers. I could just take the 15 most recent. But that includes accounts that followed us back because we followed them first — follow-backs we solicited. Those accounts are in the follow log.

An organic follower — one that is not in the follow log — found us without any prompting. That is a stronger signal that the content resonated. Their network is more likely to match the audience we want than the network of someone who followed back politely.

The code prioritizes organic seeds:

const organic = ours.filter((f) => !followedDids.has(f.did));
const followBacks = ours.filter((f) => followedDids.has(f.did));
const seeds = [...organic, ...followBacks].slice(0, SEED_LIMIT);
Enter fullscreen mode Exit fullscreen mode

Organic followers go first; follow-backs fill the remaining slots. If we have 20 recent followers and 8 are organic, the seed list is: 8 organic, then 7 follow-backs. The composition of the pool shifts depending on how many organic followers we've earned versus solicited.

In practice the accounts are still young enough that most followers are follow-backs. That will change over time — or at least, that is the hypothesis.

Pre-filter ineligible DIDs before counting the pool

After collecting candidates from all seeds, the script checks whether the pool is "thin" — too small to meet the day's follow quota. If the pool has fewer than remaining * 2 candidates, it tops up from hashtag search.

The thin-pool check compares raw candidate count to the target. That comparison is only meaningful if the pool contains genuinely new candidates. If a seed's followers are all accounts the bot has already followed, they are in the pool but will be filtered out later during the quality-filter pass. The pool looks large but has nothing in it.

Originally, known-ineligible DIDs were not removed until the quality-filter step, after the thin-pool check. The pool size counted accounts we had already followed — which we knew were ineligible — and that inflated the count above the thin-pool threshold. The tag top-up would be skipped. Then the quality filter would remove all the already-followed accounts, leaving the run with far fewer actual follows than the daily cap allowed.

The Codex P2 fix moves the pre-filter before the pool is sized:

const dids = (data.followers ?? [])
  .map((f) => f.did)
  .filter((did) => did !== session.did && !followedDids.has(did));
Enter fullscreen mode Exit fullscreen mode

Now the pool only counts accounts the script has not yet interacted with. The thin-pool comparison is accurate, and the tag top-up fires when it should.

Thin-pool fallback prevents the run from going empty

The follower-graph has a structural limit: our follow log is the dedup store, so any DID we have ever followed — even years ago, even manually — will never appear as a candidate again. As the log grows and the accounts age, the fraction of seed followers who are already in the log increases. The graph pool shrinks toward zero.

Hashtag search does not have this problem to the same degree: new accounts post under hashtags every hour. So when the graph pool falls below remaining * 2, the script tops up from tags:

if (candidates.size < remaining * 2) {
  const topUp = await collectCandidates(
    session,
    config.tags,
    wanted - candidates.size,
  );
  for (const [did, tag] of topUp) {
    if (!candidates.has(did)) candidates.set(did, tag);
  }
  console.log(
    `follower-graph pool thin, topped up from tags to ${candidates.size}`,
  );
}
Enter fullscreen mode Exit fullscreen mode

The combined pool goes to the same quality filter. The source tag on each entry (follower-of:<seed handle> or #indiehackers) is preserved in the follow log for later analysis.

I expect the follower-graph to be the primary source for another few weeks, then the pool will progressively thin as the log grows. That will be interesting data: at what log size does the graph start routinely going thin, and does the follow-back rate from the graph source hold up as the pool competes more with tag results?

I'll know in about 30 days. The log currently has 420 entries across about 10 days of history. At that pace, the log will exceed 1,000 entries before the accounts hit meaningful organic follower counts — which is when the organic-first seeding should start making a visible difference.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)