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:
- Fetch your follower list
- Save it
- Wait
- Fetch again
- 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
# .env
SOCIAVAULT_API_KEY=your_key
PLATFORM=tiktok
USERNAME=your_username
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
};
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 };
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 };
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);
}
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
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
What Makes This Better Than Apps
- Your data stays local. No third-party app storing your follower list.
- No password required. We're using public API data, not logging into your account.
- Historical tracking. After a month you have 30 snapshots. You can analyze churn patterns, identify which posts triggered unfollows, etc.
- 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.
Top comments (0)