⚡ Smart Unfollow Non-Followers on X (Twitter) — With Full Logging

Tools

📝 Smart Unfollow Non-Followers on X (Twitter) — With Full Logging

Unfollow accounts that don't follow you back on X/Twitter — with comprehensive logging, CSV + JSON export, whitelist protection, and dry-run preview. Free, no API key, no app install.

Works on: 🌐 Browser Console
Difficulty: 🟢 Beginner
Time: ⏱️ 5–60 minutes (depends on following count)
Requirements: A web browser logged into x.com

📖 For the quick-reference version, see smart-unfollow.md


🎯 Real-World Scenario

You followed 2,000+ accounts over the past year while growing your X presence — competitors, niche influencers, potential collaborators. Now your following/follower ratio is terrible (following 2,100, followers 800), which makes your profile look spammy. You need to clean up — but you want to know exactly who you unfollowed and why, because some of those accounts might actually be valuable contacts you don't want to lose.

XActions' Smart Unfollow scans your entire following list, identifies accounts that don't follow you back, and unfollows them — while logging every single action with username, display name, bio, follower count, and timestamp. At the end, it automatically downloads both JSON and CSV files so you have a complete audit trail. You can whitelist accounts you want to keep, and preview everything in dry-run mode first.

Before XActions:

┌──────────────────────────────────────────────────────────────┐
│  Cleaning Your Following List (Manual)                       │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Step 1: Go to your following list                           │
│  Step 2: For each person, check if they follow you back      │
│  Step 3: If not, click ••• → Unfollow                        │
│  Step 4: Confirm the unfollow dialog                         │
│  Step 5: Write down who you unfollowed (maybe?)              │
│  Step 6: Repeat 1,300 more times...                          │
│                                                              │
│  Time estimate: 6+ hours                                     │
│  Will you actually finish? No.                               │
│  Will you track who you unfollowed? Definitely not.          │
└──────────────────────────────────────────────────────────────┘

After XActions Smart Unfollow:

┌──────────────────────────────────────────────────────────────┐
│  Cleaning Your Following List (XActions)                     │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  Step 1: Go to x.com/YOUR_USERNAME/following                 │
│  Step 2: Paste script with dryRun: true                      │
│  Step 3: Review the preview — see exactly who'd be removed   │
│  Step 4: Re-run with dryRun: false to execute                │
│  Step 5: JSON + CSV files auto-download with full details    │
│                                                              │
│  Time: ~15 min for 1,000 accounts                            │
│  Full audit trail: ✅ (username, bio, followers, timestamp)  │
│  Whitelist protected: ✅                                     │
└──────────────────────────────────────────────────────────────┘

📋 What This Does (Step by Step)

  1. 📜 Scans your following list — reads each user cell on the page
  2. 🔍 Checks for "Follows you" badge — identifies who follows you back
  3. 🛡️ Checks whitelist — skips accounts you've protected
  4. 📊 Extracts rich data — captures username, display name, bio, follower count for the log
  5. 🔙 Unfollows non-followers — clicks the Unfollow button with confirmation
  6. ⏱️ Waits between actions — randomized 1.5–4s Gaussian delays (human-like)
  7. 📜 Scrolls for more — loads additional users by scrolling
  8. 🚨 Handles rate limits — auto-pauses for 60s if X blocks you
  9. 💾 Persists progress — saves to localStorage so you can resume
  10. 📥 Exports results — auto-downloads JSON and CSV when done
┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  [Navigate to x.com/YOU/following]                           │
│          │                                                   │
│          ▼                                                   │
│  [For each UserCell on page]                                 │
│          │                                                   │
│          ▼                                                   │
│  [Already processed?] ──Yes──→ [Skip]                        │
│          │ No                                                │
│          ▼                                                   │
│  [Has "Follows you" badge?] ──Yes──→ [Skip (mutual)]        │
│          │ No                                                │
│          ▼                                                   │
│  [In whitelist?] ──Yes──→ [Skip (protected)]                 │
│          │ No                                                │
│          ▼                                                   │
│  [Extract: username, name, bio, followers]                   │
│          │                                                   │
│          ├── DRY RUN: Log "Would unfollow @user" ──→ next    │
│          │                                                   │
│          ├── LIVE: Click Unfollow → Confirm dialog           │
│          │         Wait 1.5-4s (Gaussian random)             │
│          │         Log action with full details              │
│          │                                                   │
│          ▼                                                   │
│  [Rate limited?] ──Yes──→ [Pause 60s] ──→ [Resume]          │
│          │ No                                                │
│          ▼                                                   │
│  [Scroll for more users]                                     │
│          │                                                   │
│          ▼                                                   │
│  [All done] → [Print summary] → [Export JSON + CSV]          │
│                                                              │
└──────────────────────────────────────────────────────────────┘

🌐 Method 1: Browser Console (Copy-Paste)

Best for: Everyone — no installs, runs right in your browser.

Prerequisites

  • Logged into your X/Twitter account in a web browser
  • On a desktop/laptop (not mobile)

Step 1: Navigate to your following list

Go to x.com/YOUR_USERNAME/following — replace YOUR_USERNAME with your actual handle.

┌──────────────────────────────────────────────────────────────┐
│ 🔍 x.com/nichxbt/following                                   │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  👤 @elonmusk          Following ✓       Follows you ✗      │
│  👤 @naval             Following ✓       Follows you ✗      │
│  👤 @vitalikbuterin    Following ✓       Follows you ✗      │
│  👤 @yourfriend        Following ✓       Follows you ✓      │
│  👤 @deadaccount       Following ✓       Follows you ✗      │
│  👤 @cryptodev         Following ✓       Follows you ✓      │
│                                                              │
│  ... 2,094 more accounts below                               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Step 2: Open Developer Console

OS Shortcut
Windows / Linux F12 then click Console tab, or Ctrl + Shift + J
Mac Cmd + Option + J

Step 3: Paste and Run

Copy the entire script below. Important: Start with dryRun: true to preview before executing.

// ============================================
// XActions - Smart Unfollow with Full Log
// by nichxbt — https://xactions.app
// Go to: x.com/YOUR_USERNAME/following
// Open console (F12 → Console), paste, Enter
// ============================================

(() => {
  'use strict';

  const CONFIG = {
    maxUnfollows: Infinity,      // Set a number to limit, or Infinity for all
    whitelist: [                 // Accounts to never unfollow (without @)
      // 'elonmusk',
      // 'naval',
    ],
    dryRun: true,                // ⚠️ Start with true! Set to false to execute
    minDelay: 1500,              // Min delay between unfollows (ms)
    maxDelay: 4000,              // Max delay between unfollows (ms)
    scrollDelay: 2000,
    maxConsecutiveErrors: 8,
    maxEmptyScrolls: 6,
    rateLimitCooldown: 60000,    // 60s cooldown if rate limited
    logLevel: 'verbose',         // 'verbose' | 'normal' | 'quiet'
    exportFormat: 'both',        // 'json' | 'csv' | 'both'
  };

  const SEL = {
    unfollowBtn: ['[data-testid$="-unfollow"]', 'button[aria-label*="Following @"]'],
    confirmBtn:  ['[data-testid="confirmationSheetConfirm"]'],
    userCell:    ['[data-testid="UserCell"]', '[data-testid="cellInnerDiv"]'],
    followsYou:  ['[data-testid="userFollowIndicator"]'],
    toast:       ['[data-testid="toast"]', '[role="alert"]'],
  };

  const $ = (sel, ctx = document) => {
    for (const s of (Array.isArray(sel) ? sel : [sel])) { const e = ctx.querySelector(s); if (e) return e; }
    return null;
  };
  const $$ = (sel, ctx = document) => {
    for (const s of (Array.isArray(sel) ? sel : [sel])) { const e = ctx.querySelectorAll(s); if (e.length) return [...e]; }
    return [];
  };

  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const gaussian = (min, max) => Math.floor(min + ((Math.random() + Math.random()) / 2) * (max - min));
  const isRateLimited = () => { const t = $(SEL.toast); return t && /rate limit|try again|too many|slow down/i.test(t.textContent); };

  // ── State ──
  let paused = false, aborted = false;
  let unfollowed = 0, scanned = 0, errors = 0, consecutiveErrors = 0;
  const startTime = Date.now();
  const log = [];
  const processedUsers = new Set();
  const whitelistSet = new Set(CONFIG.whitelist.map(u => u.toLowerCase().replace(/^@/, '')));

  // Restore from localStorage
  const STORAGE_KEY = 'xactions_unfollowWDFBLog';
  try {
    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
    if (saved.processed) saved.processed.forEach(u => processedUsers.add(u));
    if (saved.log) saved.log.forEach(entry => log.push(entry));
  } catch {}

  const persist = () => {
    try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ processed: [...processedUsers], log, lastRun: new Date().toISOString() })); } catch {}
  };

  // ── Controls ──
  const showStatus = () => {
    const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
    const rate = unfollowed > 0 ? (unfollowed / (elapsed / 60)).toFixed(1) : '0';
    console.log(`📊 Unfollowed: ${unfollowed} | Scanned: ${scanned} | Errors: ${errors} | ${rate}/min | ${elapsed}s`);
  };

  const exportNow = () => {
    if (log.length === 0) { console.log('Nothing to export.'); return; }
    const ts = new Date().toISOString().slice(0, 10);
    const tag = CONFIG.dryRun ? 'preview' : 'results';

    if (CONFIG.exportFormat === 'json' || CONFIG.exportFormat === 'both') {
      const data = { summary: { scanned, unfollowed, errors, dryRun: CONFIG.dryRun, exportedAt: new Date().toISOString() }, accounts: log };
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
      const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `xactions-unfollowlog-${tag}-${ts}.json`;
      document.body.appendChild(a); a.click(); a.remove();
    }

    if (CONFIG.exportFormat === 'csv' || CONFIG.exportFormat === 'both') {
      const header = 'username,displayName,bio,followers,following,unfollowedAt\n';
      const rows = log.map(r =>
        `"${r.username}","${(r.displayName || '').replace(/"/g, '""')}","${(r.bio || '').replace(/"/g, '""').replace(/\n/g, ' ')}",${r.followers || 0},${r.following || 0},"${r.timestamp}"`
      ).join('\n');
      const blob = new Blob([header + rows], { type: 'text/csv' });
      const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `xactions-unfollowlog-${tag}-${ts}.csv`;
      document.body.appendChild(a); a.click(); a.remove();
    }

    console.log('📥 Exported.');
  };

  window.XActions = {
    pause()  { paused = true;  console.log('⏸️ Paused.'); },
    resume() { paused = false; console.log('▶️ Resumed.'); },
    abort()  { aborted = true; console.log('🛑 Aborting...'); },
    status:  showStatus,
    export:  exportNow,
  };

  const shouldContinue = async () => { while (paused && !aborted) await sleep(500); return !aborted; };

  // ── Extract Rich User Data ──
  const extractUserData = (cell) => {
    const data = { username: null, displayName: null, bio: null, followers: 0, following: 0 };
    const link = cell.querySelector('a[href^="/"][role="link"]') || cell.querySelector('a[href^="/"]');
    if (link) {
      const match = (link.getAttribute('href') || '').match(/^\/([A-Za-z0-9_]+)/);
      if (match && !['home','explore','notifications','messages','i'].includes(match[1])) data.username = match[1];
    }
    if (!data.username) {
      const spans = cell.querySelectorAll('span');
      for (const s of spans) { const m = s.textContent.match(/^@([A-Za-z0-9_]+)$/); if (m) { data.username = m[1]; break; } }
    }
    const nameSpans = cell.querySelectorAll('a[href^="/"] span');
    if (nameSpans.length > 0) data.displayName = nameSpans[0].textContent.trim();
    const bioEl = cell.querySelector('[dir="auto"]:not(a [dir="auto"])');
    if (bioEl && bioEl.textContent.length > 5) data.bio = bioEl.textContent.trim().slice(0, 300);
    return data;
  };

  // ── Main ──
  const run = async () => {
    const W = 60;
    console.log('╔' + '═'.repeat(W) + '╗');
    console.log('║  📝 UNFOLLOW NON-FOLLOWERS WITH LOG' + ' '.repeat(W - 37) + '║');
    console.log('║  by nichxbt — v2.0' + ' '.repeat(W - 21) + '║');
    console.log('╚' + '═'.repeat(W) + '╝');

    if (!window.location.href.includes('/following')) {
      console.error('❌ Navigate to x.com/YOUR_USERNAME/following first!');
      return;
    }

    console.log(`\n⚙️ Dry run: ${CONFIG.dryRun ? 'YES' : 'NO'}  |  Whitelist: ${whitelistSet.size}  |  Export: ${CONFIG.exportFormat}`);
    if (CONFIG.dryRun) console.log('   ⚠️ Preview only. Set dryRun=false to execute.\n');

    let emptyScrolls = 0;

    while (unfollowed < CONFIG.maxUnfollows && emptyScrolls < CONFIG.maxEmptyScrolls) {
      if (!(await shouldContinue())) break;
      if (isRateLimited()) { console.warn(`🚨 Rate limit! Cooling down ${CONFIG.rateLimitCooldown / 1000}s...`); await sleep(CONFIG.rateLimitCooldown); continue; }

      const cells = $$(SEL.userCell);
      let foundNew = false;

      for (const cell of cells) {
        if (!(await shouldContinue())) break;
        if (unfollowed >= CONFIG.maxUnfollows) break;

        const userData = extractUserData(cell);
        if (!userData.username) continue;
        const uLower = userData.username.toLowerCase();
        if (processedUsers.has(uLower)) continue;

        processedUsers.add(uLower);
        foundNew = true;
        scanned++;

        if ($(SEL.followsYou, cell)) continue;
        if (whitelistSet.has(uLower)) { if (CONFIG.logLevel === 'verbose') console.log(`   🛡️ Whitelisted: @${userData.username}`); continue; }

        const entry = { ...userData, timestamp: new Date().toISOString(), dryRun: CONFIG.dryRun };

        if (CONFIG.dryRun) {
          if (CONFIG.logLevel !== 'quiet') console.log(`   🔍 Would unfollow: @${userData.username}${userData.displayName ? ` (${userData.displayName})` : ''}${userData.bio ? ` — "${userData.bio.slice(0, 60)}..."` : ''}`);
          log.push(entry);
          unfollowed++;
          continue;
        }

        const btn = $(SEL.unfollowBtn, cell);
        if (!btn) { errors++; consecutiveErrors++; continue; }

        try {
          btn.click();
          await sleep(gaussian(400, 800));
          const confirm = $(SEL.confirmBtn);
          if (confirm) { confirm.click(); await sleep(gaussian(300, 600)); }
          unfollowed++;
          consecutiveErrors = 0;
          log.push(entry);
          if (CONFIG.logLevel === 'verbose' || unfollowed % 5 === 0) console.log(`   🔙 #${unfollowed} @${userData.username}${userData.displayName ? ` (${userData.displayName})` : ''}`);
          persist();
          await sleep(gaussian(CONFIG.minDelay, CONFIG.maxDelay));
        } catch (e) {
          errors++;
          consecutiveErrors++;
          if (consecutiveErrors >= CONFIG.maxConsecutiveErrors) { console.error(`❌ ${CONFIG.maxConsecutiveErrors} consecutive errors — aborting.`); break; }
        }
      }

      if (consecutiveErrors >= CONFIG.maxConsecutiveErrors) break;
      if (!foundNew) emptyScrolls++; else emptyScrolls = 0;

      window.scrollTo(0, document.body.scrollHeight);
      await sleep(gaussian(CONFIG.scrollDelay, CONFIG.scrollDelay + 1000));
    }

    const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
    console.log('\n╔' + '═'.repeat(52) + '╗');
    console.log('║  📊 FINAL RESULTS' + ' '.repeat(34) + '║');
    console.log('╠' + '═'.repeat(52) + '╣');
    console.log(`║  Scanned:       ${String(scanned).padEnd(33)}║`);
    console.log(`║  Unfollowed:    ${String(unfollowed).padEnd(33)}║`);
    console.log(`║  Errors:        ${String(errors).padEnd(33)}║`);
    console.log(`║  Duration:      ${(elapsed + 's').padEnd(33)}║`);
    console.log(`║  Mode:          ${(CONFIG.dryRun ? 'DRY RUN' : 'LIVE').padEnd(33)}║`);
    console.log('╚' + '═'.repeat(52) + '╝');

    persist();
    if (log.length > 0) exportNow();
  };

  run();
})();

Step 4: Review the dry-run output

╔════════════════════════════════════════════════════════════════╗
║  📝 UNFOLLOW NON-FOLLOWERS WITH LOG                           ║
║  by nichxbt — v2.0                                            ║
╚════════════════════════════════════════════════════════════════╝

⚙️ Dry run: YES  |  Whitelist: 0  |  Export: both
   ⚠️ Preview only. Set dryRun=false to execute.

   🔍 Would unfollow: @deadaccount123 (Dead Account) — "inactive since 2020..."
   🔍 Would unfollow: @cryptospammer (Crypto Guy) — "follow for follow..."
   🛡️ Whitelisted: @naval
   🔍 Would unfollow: @randomuser (Random) — "just vibes..."
   ...

╔════════════════════════════════════════════════════════════════╗
║  📊 FINAL RESULTS                                             ║
╠════════════════════════════════════════════════════════════════╣
║  Scanned:       847                                           ║
║  Unfollowed:    612                                           ║
║  Errors:        0                                             ║
║  Duration:      45s                                           ║
║  Mode:          DRY RUN                                       ║
╚════════════════════════════════════════════════════════════════╝

📥 Exported.

Step 5: Execute for real

Once you're satisfied with the preview, change dryRun: false and run again. The script will actually unfollow and download full logs.


⚙️ Configuration Reference

Setting Default Description
maxUnfollows Infinity Maximum accounts to unfollow (set a number to limit)
whitelist [] Usernames to never unfollow (without @)
dryRun true Preview mode — no actions taken until set to false
minDelay 1500 Minimum delay between unfollows (ms)
maxDelay 4000 Maximum delay between unfollows (ms)
scrollDelay 2000 Delay between scroll-to-bottom actions (ms)
maxConsecutiveErrors 8 Abort after this many errors in a row
maxEmptyScrolls 6 Stop if no new users found after this many scrolls
rateLimitCooldown 60000 Pause duration when rate limited (ms)
logLevel 'verbose' 'verbose', 'normal', or 'quiet'
exportFormat 'both' 'json', 'csv', or 'both'

Runtime Controls

Command Action
XActions.pause() Pause the script
XActions.resume() Resume after pause
XActions.abort() Stop immediately
XActions.status() Show progress stats
XActions.export() Force export now

📥 Export Format

JSON Output

{
  "summary": {
    "scanned": 847,
    "unfollowed": 612,
    "errors": 0,
    "dryRun": false,
    "exportedAt": "2026-02-24T14:30:00.000Z"
  },
  "accounts": [
    {
      "username": "deadaccount123",
      "displayName": "Dead Account",
      "bio": "Haven't posted since 2020",
      "followers": 12,
      "following": 890,
      "timestamp": "2026-02-24T14:15:00.000Z",
      "dryRun": false
    }
  ]
}

CSV Output

username,displayName,bio,followers,following,unfollowedAt
"deadaccount123","Dead Account","Haven't posted since 2020",12,890,"2026-02-24T14:15:00.000Z"
"cryptospammer","Crypto Guy","follow for follow DM me",3,9400,"2026-02-24T14:15:04.000Z"

💡 Pro Tips

  1. Always start with dry run. dryRun: true shows exactly who would be unfollowed. Review the CSV export, then run for real.
  2. Whitelist important accounts. Add brand partners, friends, or key influencers to the whitelist array even if they don't follow back.
  3. Use the CSV in a spreadsheet. Import the CSV into Google Sheets or Excel to sort by follower count — you might spot accounts you actually want to keep.
  4. Run in batches. Set maxUnfollows: 200 and run multiple sessions over several days instead of unfollowing 1,000+ at once.
  5. Check the exported JSON for patterns. Look at the bios of unfollowed accounts — if you see valuable contacts, add them to the whitelist and re-follow manually.
  6. Pair with the Follow Target Followers script. Unfollow non-followers → follow new niche accounts → repeat weekly. This is the classic growth loop.

⚠️ Important Warnings

Rate limits: X limits unfollow actions. The script uses Gaussian-distributed delays (1.5–4s) and auto-pauses on rate limit detection, but unfollowing 500+ accounts in one session may trigger a temporary block.

Recovery: Progress is saved to localStorage. If the page crashes, your previously processed users won't be re-processed. To clear saved state: localStorage.removeItem('xactions_unfollowWDFBLog')

"Follows you" detection: The script looks for the [data-testid="userFollowIndicator"] badge. If X changes this selector, mutuals could be accidentally unfollowed. Always preview with dry run first.


🔗 Related Features


❓ FAQ

What's the difference between this and "Unfollow Non-Followers"?

This script (unfollowWDFBLog.js) is the production-grade version with comprehensive logging (username, display name, bio, followers, timestamp), CSV + JSON export, whitelist protection, pause/resume controls, resumable state, and rate limit handling. The simpler version just unfollows without detailed logs.

Will this unfollow accounts I'm mutuals with?

No. The script checks for the "Follows you" indicator on each user cell. If someone follows you back, they're skipped automatically.

Can I undo an unfollow?

Not through the script, but you can use the exported CSV/JSON to see exactly who was unfollowed and manually re-follow them. This is why the detailed logging exists.

How long does it take to process 2,000 accounts?

With default delays (1.5–4s per unfollow), processing 2,000 accounts takes roughly 30–90 minutes depending on how many are non-followers. Scanning is fast — the delay is between actual unfollow clicks.

What happens if X rate-limits me?

The script detects rate limit toasts and automatically pauses for 60 seconds. It will resume automatically after the cooldown.

Can I resume if my browser crashes?

Yes. The script saves progress to localStorage. When you re-paste and run the script, it skips previously processed users automatically.


Built with ❤️ by @nichxbt
xactions.app · GitHub

⚡ Ready to try Smart Unfollow Non-Followers on X (Twitter) — With Full Logging?

XActions is 100% free and open-source. No API keys, no fees, no signup.

Browse All Scripts