Bulk delete your tweets, no extension needed

A free script to paste into the Chrome console for clearing out your old X posts: simulation first, recent tweets protected, deletion in capped batches. No extension, no third-party service, nothing leaves your browser.

Before you start: deleting a tweet is permanent. The script ships in simulation mode (DRY_RUN = true): it lists what would be deleted without touching anything. Download your X archive first, review the simulation, and only then switch to real deletion. Automating the interface is not something X's terms of service provide for: go slowly, at your own risk.

How to do it

  1. Open your profile: go to x.com/your_handle, the Posts tab, signed in to your account.
  2. Open the console: press F12 (or Cmd+Option+J on Mac), then click the Console tab.
  3. Configure the script: copy the code below, replace your_handle with your X username (without the @), adjust KEEP_RECENT (how many recent tweets to keep) and, if needed, list in KEEP_STATUS_IDS the IDs of the tweets to never delete.
  4. Simulate: paste the script into the console and press Enter. With DRY_RUN = true (the default setting), it shows the list of tweets that would be deleted, without deleting anything.
  5. Delete: if the simulation looks right, set DRY_RUN to false and run it again. The script deletes at most 40 tweets per run, with pauses of 6 to 14 seconds.
  6. Repeat: run the script as many times as needed to work through the rest, batch by batch.

The script

Chrome console · x.com
(() => {
  // ============ CONFIG ============
  const HANDLE          = "your_handle"; // your X username, without the @
  const KEEP_RECENT     = 15;           // NEVER deletes the 15 most recent tweets (at the top)
  const KEEP_STATUS_IDS = [             // EXTRA protection (optional): IDs to never delete
    "1234567890123456789",
  ];
  let   DRY_RUN         = true;         // true = simulation (list only). false = REAL DELETION.
  const MIN_DELAY_MS    = 6000;
  const MAX_DELAY_MS    = 14000;
  const MAX_DELETIONS   = 40;           // cap per run (run it again to continue)
  // ================================

  const me = HANDLE.toLowerCase();
  const KEEP = new Set(KEEP_STATUS_IDS.map(String));
  const protectedRecent = new Set();
  const RX_DELETE = /(Delete|delete|supprimer)/i;
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  const rand  = (a, b) => Math.floor(a + Math.random() * (b - a));
  const skipped = new Set();

  if (!location.pathname.toLowerCase().startsWith("/" + me)) {
    console.warn(`%c[STOP] Go to https://x.com/${HANDLE} (Posts tab), then run it again.`,
      "color:#e0245e;font-weight:bold");
    return;
  }

  const articles = () => [...document.querySelectorAll('article[data-testid="tweet"]')];

  const idOf = (art) => {
    const href = [...art.querySelectorAll('a[href*="/status/"]')]
      .map((x) => x.getAttribute("href"))
      .find((h) => new RegExp(`^/${HANDLE}/status/\\d+`, "i").test(h));
    if (!href) return null;
    const m = href.match(/status\/(\d+)/);
    return m ? m[1] : null;
  };

  // Loads and protects the KEEP_RECENT most recent tweets (the ones at the top).
  async function protectRecent() {
    const order = [], seen = new Set();
    let noNew = 0;
    window.scrollTo(0, 0);
    await sleep(1200);
    while (order.length < KEEP_RECENT && noNew < 6) {
      let added = 0;
      for (const art of articles()) {
        const id = idOf(art);
        if (id && !seen.has(id)) { seen.add(id); order.push(id); added++; }
      }
      noNew = added ? 0 : noNew + 1;
      if (order.length >= KEEP_RECENT) break;
      const cs = articles();
      (cs[cs.length - 1] || document.body).scrollIntoView({ block: "end" });
      await sleep(1400);
    }
    for (const id of order.slice(0, KEEP_RECENT)) protectedRecent.add(id);
    return order.slice(0, KEEP_RECENT);
  }

  const eligible = (art) => {
    const id = idOf(art);
    if (!id) return null;
    if (protectedRecent.has(id)) return null;   // one of the most recent
    if (KEEP.has(id)) return null;              // extra protected ID
    if (skipped.has(id)) return null;
    return id;
  };

  async function deleteOne(art, id) {
    const caret = art.querySelector('[data-testid="caret"]');
    if (!caret) { skipped.add(id); return false; }
    caret.click();
    await sleep(700);
    const del = [...document.querySelectorAll('[role="menuitem"]')]
      .find((i) => RX_DELETE.test((i.innerText || "").trim()));
    if (!del) { document.body.click(); skipped.add(id); return false; }
    del.click();
    await sleep(700);
    const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
    if (!confirmBtn) { skipped.add(id); return false; }
    confirmBtn.click();
    await sleep(1500);
    return true;
  }

  async function dryScan() {
    const seen = new Set();
    let noNew = 0;
    console.log("%c[SIMULATION] Nothing will be deleted. Counting...", "color:#1d9bf0;font-weight:bold");
    while (noNew < 4) {
      let added = 0;
      for (const art of articles()) {
        const id = eligible(art);
        if (id && !seen.has(id)) { seen.add(id); added++; }
      }
      noNew = added ? 0 : noNew + 1;
      const cs = articles();
      (cs[cs.length - 1] || document.body).scrollIntoView({ block: "end" });
      await sleep(1200);
    }
    console.log(`%c[SIMULATION] ${seen.size} tweet(s) WOULD be deleted (the most recent ones are protected).`,
      "color:#1d9bf0;font-weight:bold");
    console.log([...seen].slice(0, 60));
  }

  async function run() {
    let count = 0, guard = 0, noHit = 0, started = false;
    while (count < MAX_DELETIONS && guard++ < 1500) {
      const pair = articles().map((a) => [a, eligible(a)]).find(([, id]) => id);
      if (!pair) {
        if ((started && noHit >= 6) || noHit >= 40) break;
        const cs = articles();
        (cs[cs.length - 1] || document.body).scrollIntoView({ block: "end" });
        await sleep(1500);
        noHit++;
        continue;
      }
      started = true; noHit = 0;
      const [node, id] = pair;
      node.scrollIntoView({ block: "center" });
      await sleep(600);
      const ok = await deleteOne(node, id);
      if (ok) count++;
      console.log(`${ok ? "deleted" : "skipped"} ${id}  (total: ${count}/${MAX_DELETIONS})`);
      await sleep(rand(MIN_DELAY_MS, MAX_DELAY_MS));
    }
    console.log(`%c[DONE] ${count} deleted this round. Run the script again if any remain.`,
      "color:#00ba7c;font-weight:bold");
  }

  (async () => {
    console.log(`Loading and protecting the ${KEEP_RECENT} most recent tweets...`);
    const recent = await protectRecent();
    if (recent.length < KEEP_RECENT) {
      console.warn(`%c[STOP] Only ${recent.length} tweets loaded (X throttled). Reload the page (F5) and try again.`,
        "color:#e0245e;font-weight:bold");
      return;
    }
    console.log("%cProtected (the most recent):", "color:#00ba7c;font-weight:bold", recent);
    if (DRY_RUN) { await dryScan(); return; }
    await run();
  })();
})();

How the script protects you

Four safeguards are built in. First, simulation by default: as long as DRY_RUN is true, nothing is deleted, you only get the list of the tweets involved. Next, protection for recent tweets: the script starts by memorizing your most recent tweets (15 by default) and will never touch them. Then the allowlist: any ID placed in KEEP_STATUS_IDS is untouchable, handy for your pinned tweets or announcements. Finally, a cautious pace: random pauses of 6 to 14 seconds between each deletion and an automatic stop after 40, so as not to trigger X's rate limits.

Before the big cleanup, remember to archive what matters: the email and URL extractor pulls every link out of a tweet export, the word counter tells you how much your years of posting weighed, and the text cleaner tidies up a copy-paste from your archive. And for your future tweets, the Unicode styled text and the case converter are here to help. Every tool in the toolbox runs in your browser, just like this guide, in the spirit of our privacy policy.

Frequently asked questions

Can a deletion be undone?

No. A deleted tweet is gone for good, X has no trash bin. That is why the script ships in simulation mode (DRY_RUN = true): run it as-is first to get the list of what would be deleted, check it, and switch to false only when you are sure. Also remember to download your X archive beforehand (Settings, then Your account, then Download an archive of your data).

Can the script delete someone else's tweets?

No. X only shows the Delete button on your own posts: the script can act only on the account you are signed in to, on your own profile page. It never touches other people's replies or any third-party content.

Why a cap of 40 deletions and random delays?

To go easy on the platform. Deleting too fast or too much at once can trigger X's rate limits, or even a temporary account block. The script waits between 6 and 14 seconds between each deletion and stops after 40: run it again to keep going in batches.

Is this allowed by X?

You are deleting your own content, which is your right. That said, automating the web interface is not something X's terms of service provide for: use this script sparingly and at your own risk. The official alternative is manual deletion, or dedicated services that go through the API.

How do I protect specific tweets?

Two protections stack: KEEP_RECENT automatically keeps your most recent tweets (15 by default), and KEEP_STATUS_IDS accepts a list of IDs to never delete. A tweet's ID is the long number at the end of its address: x.com/handle/status/1234567890123456789.