How Topic Filter for X Removes Tweets Before They Render
Topic Filter for X is a Chrome extension that permanently hides chosen topics on X. The launch post covers what it does; this one covers the three engineering problems that made it interesting: filtering a virtualized timeline, matching keywords without false positives, and running a semantic classifier with no server.
Problem 1: You Cannot Just Hide Tweets
The obvious design for a content-blocking extension is DOM hiding: find the rendered tweets, match their text, set `display: none`. On X, this fails visibly.
X's home timeline is virtualized. Each tweet is an absolutely positioned cell placed at a fixed vertical offset inside a container with an explicit height. React computes those offsets from the data it fetched, not from what the DOM currently shows. Hide a cell and the layout does not reflow: you get a tweet-sized blank hole where the post used to be. Collapse the cell's height instead and the holes just accumulate differently. We also tried repositioning the remaining cells ourselves, which broke scrolling entirely. All three dead ends were confirmed against the live site, not in theory.
The conclusion: on a virtualized list, the DOM is a projection of the data. If you want a tweet gone, it has to disappear from the data before the list ever sees it.
Problem 2: Filtering The Data Instead
X's web app loads its timeline through GraphQL requests. So the extension injects a script into the page's MAIN world at `document_start`, before X's own code runs, and patches both `XMLHttpRequest` and `fetch`. When a timeline response comes back, the interceptor parses it, walks the `TimelineAddEntries` instructions, and deletes matching tweets from the entries array before the response is handed to X's code. React renders the already-filtered list, computes correct offsets for it, and the feed looks native: no gaps, no flicker, and infinite scroll backfills naturally.
The details that took iteration:
Content scripts normally live in an isolated world and cannot see the page's `XMLHttpRequest`. The interceptor declares `"world": "MAIN"`; a second, isolated content script bridges the user's settings across via `window.postMessage`.
X's timeline transport is XHR, not fetch (we verified; fetch is patched as insurance). Rewriting an XHR response means overriding the instance's `responseText` getter, which has the useful property of being order-independent.
Tweet text hides in several shapes: `tweet_results.result.legacy.full_text`, a nested `.tweet.legacy` variant, long-post `note_tweet` bodies, and retweets that wrap the real tweet one level deeper. Media posts are detected from `extended_entities.media[].type`. Miss a shape and that category of post silently escapes the filter.
Malformed or unrecognized responses pass through untouched. A filter that can break the timeline is worse than no filter.
Problem 3: Keywords That Do Not Embarrass You
Each topic ships a large bilingual English/Spanish keyword list, and every keyword is matched with word boundaries and accent folding, plus optional plural endings. That is what lets "$BTC", "#IA", and "elections" match while "diaria" does not match "ia" and "supermarket" does not match "market".
The harder work was negative: pruning every keyword whose everyday meaning collides with its topical one. Spanish is full of these. "Bolsa" is the stock market and also a shopping bag; "acciones" are shares and also plain actions; "sin" means "without", not a sin. Words like these were either dropped or replaced with disambiguated phrases, and a regression suite of everyday sentences guards against reintroducing them.
Problem 4: A Classifier With No Server
Keywords miss paraphrase. "The market reacted badly to yesterday's announcement" contains no listed keyword but is clearly finance. The optional semantic mode catches these with a multilingual sentence-embedding model (a MiniLM variant) running fully in the browser via transformers.js, in an MV3 offscreen document, on WebGPU with a WASM fallback. Weights download once (about 240 MB); afterwards it is offline. It is an embedding model, not a generative LLM, and no tweet text ever leaves the machine.
The finding worth sharing: raw cosine similarity between a tweet and topic prototype phrases does not separate on-topic from off-topic at all. Off-topic tweets happily scored inside the on-topic range. What worked was a contrastive margin: score a tweet against the topic prototypes and against a set of neutral "everyday life" anchor phrases, and classify on the difference. On our test set that turned an unusable signal into a clean split, with sensitivity levels mapped to margin thresholds. Prototype phrases must be concrete tweet-like sentences; abstract labels like "politics" embed too far from how people actually write.
The Boring Parts That Make It A Product
The interceptor and the matcher share one matching core, loaded into both worlds and covered by unit tests that run in CI. Releases are automated: bump the manifest version, push, and the pipeline tests, packs, uploads through the Chrome Web Store API, and auto-publishes. And the whole thing stays private by construction, because the architecture never needed a server in the first place.
If you are fighting a virtualized UI, an interception problem, or an on-device ML constraint of your own, this is the kind of work we do.