Maged Faiz

Case study · Follow Sync

Building Follow Sync without an always-on backend or a paid database

A GitHub network manager that finds non-mutual follows and inferred ghost accounts, with GitHub Gists for per-user storage and adaptive refresh limits.

Filed
Jul, 2025 · completed
Stack
Next.js / React Query / Zustand / GraphQL / Vercel / TypeScript / Tailwind CSS
Live
vercel.app
Source
github.com

01The comeback project

2024 was a heavy year of work, and by early 2025 I had stepped away from code to recover. When I came back a few months later, my GitHub looked different. The follower count had climbed while I was not paying attention, and I had lost track of who was actually in my network. Who followed me back? Who had I followed once and then forgotten about?

I went looking for something to tidy it up and found a few tools, including Hesbon Osoro's follow-for-follow-back. It got me partway there, but unfollowing meant getting bounced to each person's GitHub page to click the button by hand, and you could only deal with one account at a time. That friction was the spark. Building my own version felt like the right way to ease back into coding. It was a small problem I had, with enough rough edges to keep me interested.

02Two rules I set before writing any code

Before I started I wrote down two constraints. The tool had to stay simple, and it had to be free to run. That meant no paid database and no always-on server for me to babysit.

That second rule killed the obvious design. A backend that reads your whole network and hands the frontend a tidy answer would put every user on one GitHub allowance, so a few large networks could drain it. OAuth gave me a way around that. Each person signs in with their own account, and the GraphQL work is charged to that user's limit of 5,000 points per hour. REST is counted separately, normally 5,000 authenticated requests per hour. The sign-in asks for exactly three scopes: read:user, user:follow, and gist. GitHub's broad user scope would have been the lazy pick, but it also grants profile write access the app has no business holding, so it stayed out. That gist scope turns out to be the other half of the plan.

src/app/auth.tsview source
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub({
      // `read:user` for profile reads, `user:follow` for follow/unfollow
      // mutations, and `gist` for the private-gist cache store. This is the
      // minimal set — the broad `user` scope additionally grants profile
      // *write* access, which the app never needs.
      authorization: {
        params: { scope: 'read:user user:follow gist' },
      },
    }),
  ],
  session: {
    strategy: 'jwt',
    maxAge: 60 * 60 * 24, // 1 day
    updateAge: 60 * 60 * 6, // 6 hours
  },

  callbacks: {
    async jwt({ token, account, profile }) {
      if (account?.access_token) {
        token.accessToken = account.access_token;
        token.profile = profile;
      }
      return token;
    },
    // ...the session callback copies profile fields onto the session user.
  },
});

03The pivot: do the heavy lifting in the browser

My first instinct was to use Next.js on the server to fetch a user's network. I dropped that fast. The whole point of the tool is completeness. You can only trust the does not follow you back list once you have pulled every follower and every account someone follows. For a power user with tens of thousands of connections, a full sync runs long past the execution limits on Vercel's free tier.

So Follow Sync became a client-heavy app. The browser owns the long-running pagination and reconciliation. The server side stays thin: GitHub sign-in and two same-origin proxy routes that attach the user's token before forwarding REST and GraphQL requests. The token stays out of browser code, but there is still server-side code. What I avoided was an always-on worker doing the sync for everyone.

04Pulling an entire network without tripping the rate limit

Fetching a large network is really a paging problem. GitHub's GraphQL API returns followers and following 100 at a time, behind cursors. Instead of draining one list and then the other, I page both in the same loop, and I stop asking for whichever side finishes first by setting its page size to zero. I de-duplicate each user as it arrives, and I put a short pause between rounds so a big account does not hammer the API. Long syncs also have to survive GitHub's occasional bad response, so every page request goes through a small retry wrapper with exponential backoff. The first version died on the first error, however deep into the sync it happened; now a blip costs a retry instead of the whole run.

src/lib/gql/fetchers.tsview source
const pageSize = 100;

while (hasNextPageFollowers || hasNextPageFollowing) {
  const variables: GetUserFollowersAndFollowingQueryVariables = {
    login: username,
    firstFollowers: hasNextPageFollowers ? pageSize : 0,
    afterFollowers: currentCursorFollowers,
    firstFollowing: hasNextPageFollowing ? pageSize : 0,
    afterFollowing: currentCursorFollowing,
  };

  try {
    const data = await withRetry(() =>
      client.request<
        GetUserFollowersAndFollowingQuery,
        GetUserFollowersAndFollowingQueryVariables
      >(GET_USER_FOLLOWERS_AND_FOLLOWING, variables)
    );

    if (hasNextPageFollowers && data.user?.followers) {
      const { nodes, totalCount, pageInfo } = data.user.followers;
      mergeUniqueUsers(
        allFollowers.nodes as User[],
        nodes as User[],
        seenFollowerAccounts
      );
      if (allFollowers.totalCount === 0) {
        allFollowers.totalCount = totalCount;
      }
      hasNextPageFollowers = pageInfo?.hasNextPage || false;
      currentCursorFollowers = pageInfo?.endCursor || null;
    }

    if (hasNextPageFollowing && data.user?.following) {
      const { nodes, totalCount, pageInfo } = data.user.following;
      mergeUniqueUsers(
        allFollowing.nodes as User[],
        nodes as User[],
        seenFollowingAccounts
      );
      if (allFollowing.totalCount === 0) {
        allFollowing.totalCount = totalCount;
      }
      hasNextPageFollowing = pageInfo?.hasNextPage || false;
      currentCursorFollowing = pageInfo?.endCursor || null;
    }

    // ... report progress via onProgress?.({ ... })

    if (hasNextPageFollowers || hasNextPageFollowing) {
      await new Promise((resolve) => setTimeout(resolve, 200));
    }
  } catch (error: unknown) {
    console.error('Error fetching paginated follow data:', error);
    throw new Error(
      getErrorMessage(error, 'Failed to fetch paginated follow data.')
    );
  }
}

05Where do you keep the data without a database?

I had banned myself from using a database, but I still needed somewhere to cache a user's network so the app did not re-fetch everything on every visit. The answer was hiding in the scopes I had already asked for. A private Gist is a file that belongs to the user, it lives on GitHub, and it costs nothing. So that became the database: one private Gist per user, holding their cached network as JSON.

The trade-off is the classic one. The Gist is a snapshot, not a live mirror of your network, so what you see can lag reality by a sync. In exchange the app can open from the last snapshot without waiting for another full sync, which felt like the right side of that bargain for a tool like this. Gists also keep their full revision history. That leaves the door open to chart how a network changes over months, somewhere down the line.

Using Gists this way has one catch. There is no primary key to look something up by. I cannot query for "this user's cache." I can only list their Gists and work out which one is mine. People also accumulate duplicates over time: a Gist created on one device, another on a second, an old one left over from a previous cache version.

Rather than trust a filename and hope for the best, I score every candidate. A matching filename is worth a little. Valid JSON is worth a bit more. A matching owner login is worth more still, and an exact cache-key match is the strongest signal of all. The highest score wins and becomes canonical, and the rest get treated as duplicates and cleaned up. It is a small piece of code, but it is what makes the "no database" decision survive contact with the real world.

src/lib/gist.tsview source
export const scoreCacheGist = (gist: CacheGist, ownerLogin: string) => {
  const parsed = parseCache(gist);
  const normalizedOwnerLogin = normalizeOwnerLogin(ownerLogin);
  const expectedCacheKey = getExpectedCacheKey(normalizedOwnerLogin);

  let score = 0;

  if (hasCacheFilename(gist)) score += 10;
  if (isCacheDescription(gist.description)) score += 5;
  if (parsed) score += 20;

  const parsedOwnerLogin = parsed?.metadata.ownerLogin?.toLowerCase();
  const parsedCacheKey = parsed?.metadata.cacheKey;

  if (parsedOwnerLogin === normalizedOwnerLogin) score += 40;
  if (parsedCacheKey === expectedCacheKey) score += 80;
  if (gist.description?.includes(expectedCacheKey)) score += 20;

  return score;
};

// ... sortByRecencyDesc parses both timestamps and returns newest-first.

const selectCanonicalCacheGist = (gists: CacheGist[], ownerLogin: string) => {
  return [...gists].sort((left, right) => {
    const scoreDelta =
      scoreCacheGist(right, ownerLogin) - scoreCacheGist(left, ownerLogin);
    if (scoreDelta !== 0) {
      return scoreDelta;
    }

    return sortByRecencyDesc(left.updatedAt, right.updatedAt);
  });
};

Two details keep the gist itself healthy. The cache is written as compact JSON because pretty-printing inflates the payload by roughly a third and a single gist file has a size ceiling I would rather not meet early. Everything outside plain ASCII gets escaped on the way out, because GitHub scans gists for bidirectional Unicode and will otherwise stamp a scary warning banner across what is supposed to be an invisible cache file.

06Refreshing only as often as it is worth

Caching removed most repeat fetches, but it raised a new question. How stale is too stale? A 200-follower account can refresh often without putting much pressure on its budget. A 40,000-follower account cannot. A network with around 150,000 total connections costs roughly 1,500 GraphQL points to sync once. Four such syncs in an hour would cost about 6,000 points against the usual 5,000-point limit. This policy protects the GraphQL budget, which is separate from REST.

So the staleness scales with the size of your network. Small networks refresh on a short timer. The larger you get, the longer the app waits, until past a certain point it stops refreshing on its own and leaves it to a manual button. The quota is a fixed budget, and this just spends it in proportion to how much there is to fetch. If the defaults do not suit you, you can override the tier with your own interval in settings.

Adaptive stale times

view source
// Adaptive Stale Times (in milliseconds)
export const STALE_TIME_SMALL = 1000 * 60 * 15; // 15 minutes
export const STALE_TIME_MEDIUM = 1000 * 60 * 60 * 3; // 3 hours
export const STALE_TIME_LARGE = 1000 * 60 * 60 * 12; // 12 hours
export const STALE_TIME_MANUAL_ONLY = Infinity; // Never stale, requires manual refresh

07Reading GitHub's two APIs against each other

Deleted and suspended accounts kept skewing the numbers. They can remain in the GraphQL following list even though the profile behind them is gone. My first version pre-filtered accounts with zero followers and zero following, then sent a HEAD request to each remaining profile and read the status. It avoided GitHub API calls for the final check, but it still added one network round per suspect and needed a server route to get around CORS.

The current version gets the answer out of a disagreement between GitHub's two APIs. GraphQL following includes the entries the app treats as ghosts but omits organizations. REST following includes organizations and omits those ghost entries. After both paginated fetches finish, a login present only in GraphQL is classified as a ghost, while a REST entry typed Organization recovers an organization that GraphQL left out. The inference depends on both lists completing and on GitHub keeping those behaviors. A failed page aborts the sync instead of classifying from partial data. The extra cost is the paginated REST list, charged to its separate request budget.

src/lib/utils.tsview source
/**
 * Reconciles two completed following lists. Under the API behavior observed by
 * this app, a GraphQL-only entry is treated as a ghost. This is an inference
 * from membership in the two lists, not a deletion or suspension flag supplied
 * by GitHub. GitHub's GraphQL `FollowingConnection` contains `User` nodes, so
 * organizations cannot appear there. REST entries typed `Organization` are
 * restored to the following list.
 */
export const classifyFollowing = ({
  graphqlFollowing,
  restFollowing,
}: {
  graphqlFollowing: NetworkUser[];
  restFollowing: RestFollowingEntry[];
}): { following: NetworkUser[]; ghosts: NetworkUser[] } => {
  const restByLogin = new Map(
    restFollowing.map((entry) => [entry.login.toLowerCase(), entry])
  );

  const following: NetworkUser[] = [];
  const ghosts: NetworkUser[] = [];

  for (const user of graphqlFollowing) {
    const restEntry = restByLogin.get(user.login.toLowerCase());
    if (!restEntry) {
      // Under the observed contract, a GraphQL-only entry is a removable ghost.
      ghosts.push({ ...user, accountType: 'ghost', removable: true });
    } else {
      following.push({ ...user, accountType: 'user' });
    }
  }

  // GraphQL FollowingConnection contains User nodes, so restore orgs from REST.
  const classifiedLogins = new Set(
    graphqlFollowing.map((u) => u.login.toLowerCase())
  );
  for (const entry of restFollowing) {
    if (entry.type !== 'Organization') continue;
    const login = entry.login.toLowerCase();
    if (classifiedLogins.has(login)) continue;
    classifiedLogins.add(login);
    following.push(restEntryToNetworkUser(entry));
  }

  return { following, ghosts };
};

Getting rid of a ghost has the same two-API shape. GraphQL's unfollow mutation cannot touch an account that no longer really exists, so ghost removal goes through the REST unfollow endpoint instead, which works by login alone. The same diff also flags ghosts among your followers, though those just get labeled, since there is nothing on your side to remove.

08Splitting state by concern

As the app grew, one store was holding the network, the ghosts, the gist metadata, and the cache logic at once. I split those concerns into separate stores and let useCacheManager coordinate them. The cache-or-refetch choice moved into a pure function with no React or side effects, so a table of cases can test it directly. Gist writes also pass through a queue, which stops two saves from interleaving and overwriting each other.

09Updating first, rolling back on failure

The last piece was making the app pleasant to use. Following and unfollowing go through optimistic updates. The UI changes the moment you click, before GitHub has confirmed anything, and if the request fails it rolls the change back and tells you what happened. The undo itself belongs to the network store: each optimistic call hands back its own rollback function, so the hook never has to remember what the world looked like before. Bulk actions reuse the same machinery, one request at a time with a small delay between them to stay clear of GitHub's secondary rate limits, and save to the gist once at the end of the batch instead of after every click.

src/lib/hooks/useFollowManager.tsview source
/**
 * Connection mutations. The optimistic update + rollback live in the network
 * store (the single source of truth); this hook just wires them to the API call
 * and persistence. Single-action mutations persist on success; the bulk
 * variants skip per-item persistence so callers can persist once at the end.
 */
export const useFollowManager = () => {
  // ...

  const followMutation = useMutation<unknown, Error, FollowMutationInput, MutationContext>({
    mutationFn: ({ user }) =>
      followUser({ client: requireClient(), userId: user.id }),
    onMutate: ({ user }) => ({ rollback: optimisticFollow(user) }),
    onError: (err, { user }, context) => {
      context?.rollback();
      toast.error(toUserMessage(err, `Failed to follow @${user.login}.`));
    },
    onSuccess: async () => {
      await persistChanges();
    },
  });

  // ...unfollowMutation mirrors it, and the bulk variants reuse the same
  // store-owned rollback while persisting once at the end of the batch.
};

10What it ended up proving

Follow Sync runs without a paid database or an always-on sync service, and I have used it on networks in the tens of thousands. The browser does the long job, GitHub supplies the per-user storage and API budgets, and the server keeps the token out of browser code. The stack underneath is Next.js with React Query for the async state and Zustand for the rest, but the useful decisions were about what not to build.

Building Follow Sync without an always-on backend or a paid database