"You're not reading the news. You're reading a side of it."
That thought kept nagging me.
I'd read an article about an event, feel informed, then stumble on coverage from the other side of the political spectrum and realize I'd only seen half the picture. Not because anyone lied, but because every outlet frames the same event differently.
So I built OpenNews — an open-source news aggregator that pulls from 116 RSS feeds across the political spectrum and uses AI to cluster articles covering the same story together.
Instead of reading one version of an event, you can see how it's covered across the spectrum, side by side.
The twist: everything runs in your browser. No backend AI. No tracking. No accounts.
Why I Built It
Most news aggregators optimize for convenience. I wanted one that optimized for perspective.
The problem is not just bias. It's fragmentation. The same event gets described with different emphasis, tone, and framing depending on the outlet. If you only read one source, you often walk away with an incomplete picture.
OpenNews tries to solve that by grouping reporting on the same event together, then showing how different outlets are covering it.
How the Architecture Works
The entire intelligence layer is client-side.
The only server component is a tiny Vercel Edge Function that acts as a CORS proxy for RSS feeds. Everything else happens in the browser.
Here's the pipeline.
1. Fetch
OpenNews pulls from 116 RSS feeds through the CORS proxy.
Each feed has a circuit breaker: after 3 consecutive failures, it enters a 10-minute cooldown. That keeps the app responsive even when some sources are down.
2. Parse
rss-parser converts XML into JavaScript objects.
Articles are then deduplicated by URL hash and filtered by age so the app focuses on fresh coverage.
3. Embed
This is where it gets interesting.
Using Transformers.js, the browser loads the all-MiniLM-L6-v2 model (about 23MB) via WebAssembly and runs it entirely on the client.
After the first load, the model is cached in IndexedDB, so subsequent visits are much faster.
Each headline becomes a 384-dimensional embedding vector.
4. Cluster
Once every article has an embedding, the app performs agglomerative clustering with cosine similarity.
It builds a similarity matrix, then repeatedly merges the most similar pair until the similarity drops below a threshold.
The result is a set of clusters, where each cluster represents multiple outlets covering the same story.
5. Render
Stories are ranked by coverage breadth and recency.
Each story shows:
- how many left, center, and right outlets are covering it
- a publication timeline
- sentiment analysis
- the individual articles grouped under the same event
Running ML in the Browser: What I Learned
It actually works
I went into this wondering whether browser-based ML would feel like a gimmick.
It doesn't.
Transformers.js running ONNX models through WebAssembly is fast enough for this use case. The all-MiniLM-L6-v2 model is small enough to load quickly and good enough to generate meaningful semantic similarity between headlines.
Related articles end up close together. Unrelated ones don't. For clustering news stories, that's enough to be genuinely useful.
Memory management matters
WASM doesn't behave like the rest of the JavaScript world. You can't just assume memory will clean itself up.
Every tensor output from the model needs to be manually disposed:
const output = await model(inputs);
const embedding = Array.from(output.data);
if (typeof output.dispose === "function") {
output.dispose();
}
Skip that, and memory usage climbs until the browser eventually kills the tab.
Caching is critical
Re-embedding unchanged articles is wasteful.
I keep an embeddings cache in a useRef<Map> so embeddings persist between clustering runs. On each cycle, stale entries are pruned for articles that are no longer present in the feed.
That avoids unnecessary recomputation and prevents slow memory growth over time.
Race conditions are sneaky
The clustering pipeline is async and can take several seconds.
At first, I used a useState flag to prevent overlapping runs. That looked correct, but the async flow meant stale closures could still let a second run slip through before the first one finished.
The fix was switching to useRef.
Because the ref always holds the current value, it doesn't get trapped in an outdated closure.
That was one of those bugs that only shows up under real usage.
The "Zero Backend" Setup
The CORS proxy is intentionally dumb. It only does four things:
- validates the URL protocol (
httporhttps) - checks the hostname against a domain allowlist
- fetches the RSS XML
- returns the response with CORS headers
That's it.
No article processing. No database. No analytics. No user tracking. No logging pipeline.
User settings like feed toggles, AI provider selection, and API keys live in localStorage.
The article cache lives in sessionStorage so reloads feel instant.
If someone enables optional AI summaries, the API key is stored locally and requests go directly from the browser to the provider. The key never touches my infrastructure.
Why This Approach Felt Worth It
There are a lot of apps that say they respect privacy, but still centralize all the intelligence on the server.
I wanted to see how far I could push the opposite model:
- do the heavy lifting in the browser
- keep the server as close to stateless as possible
- let users inspect the whole stack
- avoid collecting anything unnecessary
It turned out to be more practical than I expected.
What's Next
A few things I want to add next:
- custom RSS feeds so users can bring their own sources
- shareable story URLs
- Web Workers for clustering large batches without freezing the UI
- topic-based filtering
- more sources from underrepresented regions
Try It
Open the live demo — no signup, no install, instant.
Browse the GitHub repo — MIT licensed.
One of the most useful contributions right now is adding RSS feeds, especially from underrepresented regions.
The news usually isn't lying to you.
It's just not telling you everything.
OpenNews helps you see the rest.

Top comments (0)