DEV Community

Cover image for Build a "Who Unfollowed Me" Tracker with Node.js
Olamide Olaniyan
Olamide Olaniyan

Posted on

Build a "Who Unfollowed Me" Tracker with Node.js

Three months ago I had 12,400 followers. Now I have 12,380. That's 20 people who decided I wasn't worth it.

But who? And more importantly — was it organic churn or did a competitor poach them?

Most "who unfollowed me" apps are either dead (RIP Crowdfire), full of ads, or ask for your password (please don't). So I built my own. It runs on my machine, stores data locally, and tells me exactly who left.

How It Works

The concept is embarrassingly simple:

  1. Fetch your follower list
  2. Save it
  3. Wait
  4. Fetch again
  5. Diff

The followers in snapshot #1 that aren't in snapshot #2 — those are your unfollowers. The followers in #2 that weren't in #1 — those are new followers.

The Stack

  • Node.js – runtime
  • SociaVault API – fetch follower lists
  • better-sqlite3 – local storage for snapshots
  • node-cron – schedule daily checks

The Catch

Fetching a full follower list for accounts with 100K+ followers is expensive and slow. For accounts under 10K, it's fine — a few API calls and you have everyone.

For larger accounts, we'll sample. Grab the most recent 5,000 followers each day. That catches the churn that matters — the people who followed recently and then bounced.

Setup

mkdir unfollow-tracker && cd unfollow-tracker
npm init -y
npm install axios better-sqlite3 node-cron dotenv
Enter fullscreen mode Exit fullscreen mode
# .env
SOCIAVAULT_API_KEY=your_key
PLATFORM=tiktok
USERNAME=your_username
Enter fullscreen mode Exit fullscreen mode

Step 1: Database

// db.js
const Database = require('better-sqlite3');
const db = new Database('./followers.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS snapshots (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    taken_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );

  CREATE TABLE IF NOT EXISTS snapshot_followers (
    snapshot_id INTEGER NOT NULL,
    user_id TEXT NOT NULL,
    username TEXT,
    display_name TEXT,
    follower_count INTEGER DEFAULT 0,
    PRIMARY KEY (snapshot_id, user_id),
    FOREIGN KEY (snapshot_id) REFERENCES snapshots(id)
  );

  CREATE TABLE IF NOT EXISTS unfollow_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id TEXT NOT NULL,
    username TEXT,
    display_name TEXT,
    unfollowed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    was_follower_since DATETIME
  );

  CREATE TABLE IF NOT EXISTS follow_events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id TEXT NOT NULL,
    username TEXT,
    display_name TEXT,
    followed_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );
`);

module.exports = {
  createSnapshot: () => {
    const result = db.prepare('INSERT INTO snapshots DEFAULT VALUES').run();
    return result.lastInsertRowid;
  },

  addFollowerToSnapshot: db.prepare(`
    INSERT INTO snapshot_followers (snapshot_id, user_id, username, display_name, follower_count)
    VALUES (?, ?, ?, ?, ?)
  `),

  getLatestSnapshotId: () => {
    const row = db.prepare('SELECT id FROM snapshots ORDER BY id DESC LIMIT 1').get();
    return row?.id || null;
  },

  getPreviousSnapshotId: () => {
    const row = db.prepare('SELECT id FROM snapshots ORDER BY id DESC LIMIT 1 OFFSET 1').get();
    return row?.id || null;
  },

  getFollowerIds: (snapshotId) => {
    return db.prepare('SELECT user_id, username, display_name FROM snapshot_followers WHERE snapshot_id = ?')
      .all(snapshotId);
  },

  recordUnfollow: db.prepare(`
    INSERT INTO unfollow_events (user_id, username, display_name) VALUES (?, ?, ?)
  `),

  recordFollow: db.prepare(`
    INSERT INTO follow_events (user_id, username, display_name) VALUES (?, ?, ?)
  `),

  getRecentUnfollows: (days = 7) => {
    return db.prepare(`
      SELECT * FROM unfollow_events
      WHERE unfollowed_at > datetime('now', '-' || ? || ' days')
      ORDER BY unfollowed_at DESC
    `).all(days);
  },

  getRecentFollows: (days = 7) => {
    return db.prepare(`
      SELECT * FROM follow_events
      WHERE followed_at > datetime('now', '-' || ? || ' days')
      ORDER BY followed_at DESC
    `).all(days);
  },

  getStats: () => {
    return {
      totalSnapshots: db.prepare('SELECT COUNT(*) as count FROM snapshots').get().count,
      totalUnfollows: db.prepare('SELECT COUNT(*) as count FROM unfollow_events').get().count,
      totalFollows: db.prepare('SELECT COUNT(*) as count FROM follow_events').get().count,
    };
  },

  db, // expose for transactions
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Fetch Followers

// fetcher.js
const axios = require('axios');

const api = axios.create({
  baseURL: 'https://api.sociavault.com/v1/scrape',
  headers: { 'x-api-key': process.env.SOCIAVAULT_API_KEY },
});

async function fetchFollowers(platform, username, maxCount = 5000) {
  const followers = [];
  let cursor = null;

  const endpoint = platform === 'tiktok'
    ? `/tiktok/followers?username=${username}`
    : `/instagram/followers?username=${username}`;

  while (followers.length < maxCount) {
    const params = new URLSearchParams({
      limit: String(Math.min(200, maxCount - followers.length)),
    });
    if (cursor) params.set('cursor', cursor);

    try {
      const { data } = await api.get(`${endpoint}&${params.toString()}`);

      const batch = data.data || data.followers || [];
      followers.push(...batch);

      cursor = data.nextCursor || data.cursor;
      if (!cursor || batch.length === 0) break;

      console.log(`  ...fetched ${followers.length} followers`);
    } catch (err) {
      console.error(`Fetch error: ${err.message}`);
      break;
    }
  }

  return followers.map(f => ({
    userId: f.userId || f.id || f.uniqueId,
    username: f.uniqueId || f.username || 'unknown',
    displayName: f.nickname || f.fullName || f.uniqueId || '',
    followerCount: f.followerCount || f.followersCount || 0,
  }));
}

module.exports = { fetchFollowers };
Enter fullscreen mode Exit fullscreen mode

Step 3: The Diff Engine

This is where it gets interesting:

// diff.js
const db = require('./db');

function computeDiff() {
  const latestId = db.getLatestSnapshotId();
  const previousId = db.getPreviousSnapshotId();

  if (!latestId || !previousId) {
    console.log('Need at least 2 snapshots to compute diff.');
    return null;
  }

  const latest = db.getFollowerIds(latestId);
  const previous = db.getFollowerIds(previousId);

  const latestSet = new Map(latest.map(f => [f.user_id, f]));
  const previousSet = new Map(previous.map(f => [f.user_id, f]));

  // Unfollowers: were in previous, not in latest
  const unfollowers = [];
  for (const [userId, follower] of previousSet) {
    if (!latestSet.has(userId)) {
      unfollowers.push(follower);
    }
  }

  // New followers: in latest, not in previous
  const newFollowers = [];
  for (const [userId, follower] of latestSet) {
    if (!previousSet.has(userId)) {
      newFollowers.push(follower);
    }
  }

  // Record events
  const recordUnfollows = db.db.transaction((unfollows) => {
    for (const u of unfollows) {
      db.recordUnfollow.run(u.user_id, u.username, u.display_name);
    }
  });

  const recordFollows = db.db.transaction((follows) => {
    for (const f of follows) {
      db.recordFollow.run(f.user_id, f.username, f.display_name);
    }
  });

  recordUnfollows(unfollowers);
  recordFollows(newFollowers);

  return {
    unfollowers,
    newFollowers,
    netChange: newFollowers.length - unfollowers.length,
  };
}

module.exports = { computeDiff };
Enter fullscreen mode Exit fullscreen mode

Step 4: Main Script

// index.js
require('dotenv').config();
const cron = require('node-cron');
const { fetchFollowers } = require('./fetcher');
const { computeDiff } = require('./diff');
const db = require('./db');

const PLATFORM = process.env.PLATFORM;
const USERNAME = process.env.USERNAME;

async function takeSnapshot() {
  console.log(`\n[${new Date().toISOString()}] Taking snapshot for ${PLATFORM}/@${USERNAME}`);

  // Fetch current followers
  const followers = await fetchFollowers(PLATFORM, USERNAME, 5000);
  console.log(`Fetched ${followers.length} followers`);

  // Save snapshot
  const snapshotId = db.createSnapshot();
  const insert = db.db.transaction((data) => {
    for (const f of data) {
      db.addFollowerToSnapshot.run(snapshotId, f.userId, f.username, f.displayName, f.followerCount);
    }
  });
  insert(followers);

  // Compute diff against previous snapshot
  const diff = computeDiff();

  if (diff) {
    console.log('\n--- RESULTS ---');
    console.log(`New followers: +${diff.newFollowers.length}`);
    console.log(`Unfollowers: -${diff.unfollowers.length}`);
    console.log(`Net change: ${diff.netChange > 0 ? '+' : ''}${diff.netChange}`);

    if (diff.unfollowers.length > 0) {
      console.log('\nWho unfollowed:');
      for (const u of diff.unfollowers) {
        console.log(`  ❌ @${u.username} (${u.display_name})`);
      }
    }

    if (diff.newFollowers.length > 0) {
      console.log('\nNew followers:');
      for (const f of diff.newFollowers.slice(0, 10)) {
        console.log(`  ✅ @${f.username} (${f.display_name})`);
      }
      if (diff.newFollowers.length > 10) {
        console.log(`  ... and ${diff.newFollowers.length - 10} more`);
      }
    }
  }
}

// Run commands
const command = process.argv[2];

if (command === 'snapshot' || command === '--now') {
  takeSnapshot().then(() => process.exit(0));

} else if (command === 'unfollows') {
  const days = parseInt(process.argv[3]) || 7;
  const unfollows = db.getRecentUnfollows(days);
  console.log(`\nUnfollowers in the last ${days} days:`);
  for (const u of unfollows) {
    console.log(`  ❌ @${u.username}${new Date(u.unfollowed_at).toLocaleDateString()}`);
  }
  console.log(`\nTotal: ${unfollows.length}`);
  process.exit(0);

} else if (command === 'stats') {
  const stats = db.getStats();
  console.log(`\nSnapshots taken: ${stats.totalSnapshots}`);
  console.log(`Total unfollows tracked: ${stats.totalUnfollows}`);
  console.log(`Total new follows tracked: ${stats.totalFollows}`);
  process.exit(0);

} else {
  // Default: run on schedule
  console.log(`Unfollow tracker started for ${PLATFORM}/@${USERNAME}`);
  console.log('Taking daily snapshots at 8am...');

  // Initial snapshot
  takeSnapshot();

  // Daily at 8am
  cron.schedule('0 8 * * *', takeSnapshot);
}
Enter fullscreen mode Exit fullscreen mode

Usage

# Take a snapshot now
node index.js snapshot

# Wait a day (or at least a few hours), take another
node index.js snapshot

# See who unfollowed
node index.js unfollows

# See unfollows from last 30 days
node index.js unfollows 30

# See overall stats
node index.js stats

# Run as daemon (takes daily snapshots)
node index.js
Enter fullscreen mode Exit fullscreen mode

Sample Output

[2026-04-05T08:00:00.000Z] Taking snapshot for tiktok/@your_username
Fetched 4,832 followers

--- RESULTS ---
New followers: +47
Unfollowers: -12
Net change: +35

Who unfollowed:
  ❌ @former_fan_123 (Mike Johnson)
  ❌ @brand_that_left (Old Partner Brand)
  ❌ @competitor_spy (lol)
  ❌ @random_user_1 (Unknown)
  ...8 more

New followers:
  ✅ @new_person (Sarah K)
  ✅ @interested_brand (Cool Brand)
  ... and 45 more
Enter fullscreen mode Exit fullscreen mode

What Makes This Better Than Apps

  1. Your data stays local. No third-party app storing your follower list.
  2. No password required. We're using public API data, not logging into your account.
  3. Historical tracking. After a month you have 30 snapshots. You can analyze churn patterns, identify which posts triggered unfollows, etc.
  4. Customizable. Want a Slack notification when someone with 10K+ followers unfollows? Add 5 lines of code.

Extending It

  • Notification on high-value unfollows — if someone with 50K+ followers leaves, you probably want to know
  • Correlation with posting — did unfollows spike after a specific post?
  • Weekly email digest — combine with the weekly report script from my other article
  • Web UI — simple Express + HTML table showing follow/unfollow history

Read the Full Guide

Build a Who Unfollowed Me Tracker → SociaVault Blog


Track followers, posts, and engagement across platforms with SociaVault — unified API for TikTok, Instagram, YouTube, and 10+ social platforms.

Discussion

Do you track who unfollows you? Is it useful intel or just anxiety fuel? Genuinely curious how others think about this.

javascript #nodejs #api #webdev #tutorial

Top comments (0)