Status pages don't aggregate. AI news lives in 60 RSS feeds. MCP servers are scattered across awesome-lists.
So I built Prismix - one URL for all three - on Cloudflare Workers + Astro 5. Here's what broke and what fixed it.
Live: prismix.dev
Why this exists
Three daily frustrations:
- OpenAI / Anthropic status pages don't aggregate — I had 5 tabs open every morning
- AI news lives in 60 different RSS feeds and Twitter accounts
- MCP servers are scattered across awesome-lists with no canonical index
Prismix solves all three on one page.
The stack
| Layer | Choice | Why |
|---|---|---|
| Framework | Astro 5 SSR | Islands architecture, $0 hosting |
| Hosting | Cloudflare Pages | Free tier covers MVP |
| Database | Cloudflare Workers KV | No DB to operate |
| Auth | Self-rolled (email + GitHub) | Saved $25/mo vs Clerk |
| Payments | Ko-fi | No Stripe overhead |
| Tests | Vitest + MockKV | 1112 tests, runs in 8s |
The whole thing costs $0 to run. That constraint forced 4 lessons.
Lesson 1 — KV writes are the rare resource
Cloudflare's free tier gives you 100,000 KV reads/day but only 1,000 writes. I hit the wall at 1pm on day 12.
The fix wasn't fewer writes - it was a diff-or-skip pattern: read the existing value, compare, only write if changed.
async function setIfChanged(kv: KVNamespace, key: string, value: string) {
const existing = await kv.get(key);
if (existing === value) return false; // skip
await kv.put(key, value);
return true;
}
Cron writes dropped from 8,400/day → 600/day. Reads are 100× more generous - trade them freely.
Lesson 2 - JSON.parse has a cliff at 1 MB
The news index grew to 5,000 items. Each KV read returned a 3.2 MB JSON blob. SSR for /news was taking 330ms just to parse.
The fix was splitting the index into a "first page" slice (250 items, 165 KB) and a meta blob (facet counts, 12 KB). When the user hits /news with no filters or pagination, we only read the slice.
// hot path — 17ms
const firstPage = await kv.get('news:index:latest:firstpage:v1', 'json');
const meta = await kv.get('news:index:latest:meta:v1', 'json');
// cold path (filters, sort, page>1) falls back to full index
Parse time: 330ms → 17ms.
Lesson 3 — CF Workers subrequest concurrency is per-request
This one bit me hardest. /mcp was rendering in 5.5s. Server-Timing showed 4.31s spent on KV reads.
I assumed 60 parallel kv.get() calls would run in parallel. They don't — Workers caps concurrent subrequests at ~6, so the rest queue.
The fix: a denormalised snapshot blob updated by cron once a day.
// Before: 60 parallel reads, serialised by concurrency limit → 4.31s
const likes = await Promise.all(slugs.map(s => kv.get(`mcp:likes:${s}`)));
// After: 1 blob read → 30ms
const snapshot = await kv.get('mcp:bulk-engagement:v1', 'json');
Total /mcp SSR time: 5460ms → 380ms.
Lesson 4 — Even fast SSR feels broken without instant feedback
After fixing perf, the site loaded in 200-400ms. Users still complained it was slow.
The problem: the browser doesn't repaint the address bar until the new HTML arrives. For 200ms the user sees nothing — they click again, thinking the button didn't register.
Two fixes:
-
Pointerdown progress bar — render a 2px green bar at the top of the screen on
pointerdown, before the navigation even fires. -
Astro
tapprefetch — start fetching the next page onpointerdown, finish during the click delay.
document.addEventListener('pointerdown', (e) => {
const link = e.target.closest('a[href]');
if (link) showProgressBar();
});
Perceived load time dropped from "slow" to "instant" without a single SSR optimisation.
What didn't work
- Algolia for MCP search — overkill; switched to in-memory tokenised index
- Stripe — Ko-fi's webhook + idempotency was 1 day vs Stripe's week
- Clerk — self-rolled OTP + GitHub OAuth was 3 days, $0/mo
-
GitHub Actions
schedule:— drifts by minutes, silently skips. Moved cron to cron-job.org
What's next
- Per-service alert thresholds (Pro feature)
- AI news instant-notify via webhook (Discord / Slack)
- Public read API + SDK
If you've shipped on Cloudflare Workers and hit different walls, I'd love to hear what broke for you. Comments open.
Top comments (0)