Skip to content
Mediaorigo

Back to Journal

Real-time article distribution across 12 channels: eight seconds end-to-end

Published: · distribution

Twelve channels, eight-second median, an OG cache warmer — and the 22-minute outage caused by a quiet Cloudflare cache-key change.

A year ago the gap between pressing 'publish' and the article appearing on the last distribution channel ran 90–120 seconds. Today that gap is eight seconds across twelve channels. Not because we wait faster — because we broke the monolith apart.

The twelve channels

Web (canonical URL), AMP, RSS, homepage cache, category cache, Apple News, Google News, Facebook Instant Articles, a Telegram channel, the email alerts band, push notifications to the mobile app, and the OG preview (Open Graph image plus meta). The list of twelve is not a design decision — it is the minimum distribution spectrum a serious Central European news publisher operates in 2026.

Schema-per-channel transformer

Every article lives in an internal JSON shape we call canonical. Twelve transformers run against it, each returning the format of a different channel. Apple News output is declarative, AMP is HTML5-subset, Telegram is markdown-ish text, the OG preview is a 1200x630 PNG, and so on. The transformers are pure functions — same input, same output. Unit-testable, parallel-safe, no shared state, no shared dependency graph.

OG image cache warming

The Open Graph image is generated with Vercel's @vercel/og library, but not on the request that first asks for it. At publish time the fan-out worker triggers generation and warms the Cloudflare edge cache for the four highest-traffic POPs in our regions (Frankfurt, London, Amsterdam, Madrid). By the time the first X/Twitter crawler asks for the OG — usually 3–5 seconds after publish — it is a cache hit. We introduced this after tracing every 'X isn't showing my preview for an hour' complaint back to cold-start OG generation.

Fan-out via BullMQ

The twelve channels run as twelve separate BullMQ queues. Each has its own concurrency limit, its own retry policy, its own dead-letter queue. The publish endpoint enqueues twelve jobs and returns. Workers process in parallel — the slowest is Google News (1.5–4s, because their ingest API uses poll-based acknowledgment), the fastest is the homepage cache invalidation (50–80ms, because it is internal to our own CDN).

End-to-end median is 8 seconds, p95 is 12 seconds, p99 is 26 seconds. The p99 is always Google News.

The 22-minute outage

Last month Cloudflare made an unannounced change to how Vary headers contribute to the cache key on the enterprise tier. Our OG cache warm-up depended on the old key format. Result: for 22 minutes every freshly-published article's OG preview missed cache and went cold-start, which on its own is just slow — but our fan-out worker timeout was 15 seconds, which a cold OG generation with an 800ms Inter font fetch and a 1200x630 SSR render does not reliably fit inside. The cascade: timeouts, retries, queue backpressure.

The fix took 22 minutes: cache-key normalization on our side, plus worker timeout raised to 30 seconds. The longer lesson: every upstream infra vendor will quietly change something, and our own timeouts should be generous enough to absorb it.

What we have not solved

Channel-specific editorial feedback — for example, Apple News editorial proposing a different lead image — does not flow back into our CMS. We handle it manually, by email and copy-paste. It is tolerable but embarrassing. It is on the next-half roadmap.

The other open problem is partial-publish reconciliation. When channel six in the fan-out has succeeded but channel seven has dead-lettered, there is no UI surface that tells the editor 'eleven out of twelve are live, here is the one that is not'. Today this lives in Grafana dashboards that no editor reads. The right surface is in-CMS, not in the observability stack.