Maged Faiz

Case study · Follow Sync

Building Follow Sync with no server and no database I pay for

A serverless GitHub manager for finding non-mutuals. Uses a client-heavy architecture with GitHub Gists as a database and adaptive caching to respect API 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 actually 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 shares one GitHub API quota across every user, so a few people with large networks would drain it and everyone else would hit a wall. GitHub's OAuth gave me a way around that. Each person signs in with their own account, so each person brings their own quota of 5,000 requests per hour. The app never spends my budget. It spends yours. 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 does the long-running work, and what stays on the server is deliberately thin: the sign-in, plus a pair of proxy routes that stamp your GitHub token onto each request on the server side. The token never reaches browser code, so nothing running in the page can read it.

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 opens instantly off the last snapshot instead of re-pulling tens of thousands of connections, which felt like the right side of that bargain for a tool like this. There was a quiet bonus too. Gists keep their full revision history, so every sync is secretly a snapshot in time. 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 quieter details keep the gist itself healthy. The cache is written as compact JSON on purpose, 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. And 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 killed the cost of re-fetching, but it raised a new question. How stale is too stale? A 200-follower account can refresh constantly and never notice. A 40,000-follower account cannot. GraphQL bills by the size of what you ask for, so a network with around 150,000 total connections costs roughly 1,500 points to sync once. Refresh that every 15 minutes and you are spending about 6,000 points an hour against a 5,000-point ceiling. It just breaks.

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

07Finding the ghosts for free

One thing kept skewing the numbers: deleted and suspended accounts, the ghosts. They still sit in your following list, but the user behind them is gone, so they quietly count against your non-mutuals. Confirming a ghost the thorough way means asking the API about each suspect, and that spends the very quota I was trying to protect. My first version dodged the cost with a trick: a deleted profile page returns a 404, so the app pre-filtered suspects down to accounts with zero followers and zero following, then sent plain HEAD requests at the survivors and read the status codes. Free, but it was still an extra network round for every suspect, and it needed its own server route to get around CORS.

The version running now gets the answer without asking anyone anything. It fell out of a disagreement between GitHub's own APIs. The GraphQL following list includes ghosts but hides organizations. The REST following list includes organizations but drops ghosts. Neither list is the truth alone, but they are mirror images of each other, so reading them against each other classifies every single account you follow: in GraphQL but missing from REST means ghost, typed Organization in REST means an org GraphQL was hiding. It comes down to one diff, with no extra quota spent beyond fetching the second list.

src/lib/utils.tsview source
/**
 * Reconciles the GraphQL following list against the REST following list to
 * classify every followed account. The two GitHub APIs are mirror images:
 *
 * - GraphQL `following` returns active users AND ghosts, but omits organizations.
 * - REST `/user/following` returns active users AND organizations, but omits ghosts.
 *
 * So: a login in GraphQL but absent from REST is a ghost (deleted/suspended),
 * and any REST entry typed `Organization` is an org that GraphQL hid from us.
 */
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) {
      // In your following list but gone from REST => removable ghost.
      ghosts.push({ ...user, accountType: 'ghost', removable: true });
    } else if (restEntry.type === 'Organization') {
      following.push({ ...user, accountType: 'organization' });
    } else {
      following.push({ ...user, accountType: 'user' });
    }
  }

  // Organizations are never returned by GraphQL, so add them from REST.
  const graphqlLogins = new Set(
    graphqlFollowing.map((u) => u.login.toLowerCase())
  );
  for (const entry of restFollowing) {
    if (entry.type !== 'Organization') continue;
    if (graphqlLogins.has(entry.login.toLowerCase())) continue;
    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.

08Keeping the state honest

As the app grew, a single store trying to hold the network, the ghosts, the gist metadata, and the cache logic all at once turned brittle. I split it into focused stores, one per concern, and made useCacheManager the brain that drives them. Each store owns its own slice now, instead of one god store knowing everything. The judgment call at the middle of it, serve the cache or refetch, got pulled out of the hooks entirely into a pure function that takes what the app knows and returns a decision. It has no React in it and no side effects, just a function you can test with a table of cases. And writes to the gist line up behind a small queue, so two saves can never interleave and half-overwrite each other.

09Making every action feel instant

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 with no database I pay for and no server I maintain, and it still handles networks in the tens of thousands. Storage, caching, rate limits, ghost detection: every hard part got solved by leaning on what GitHub already hands each user, down to reading GitHub's own two APIs against each other. The stack underneath is Next.js with React Query for the async state and Zustand for the rest, but the interesting decisions were never about the libraries. They were about what not to build.