Streaming Live Solana Trades Through Cloudflare Workers (The Wire-Splice Trick)
Streaming Live Solana Trades Through Cloudflare Workers (The Wire-Splice Trick)
Cloudflare Workers "can't hold long-lived connections." So how is my Next.js app streaming live Solana trades through one, with the bytes flowing continuously for as long as the tab is open?
The answer is the most fun part of this build, and it's a little counterintuitive: the Worker isn't a stream server. It's a wire splice. But before we get there, let me walk the whole pipeline, because the splice only makes sense once you see what's on both ends of it.
1. Ingest: Solana into a database
The system of record starts with a long-running Rust/Axum service that subscribes to a Solana validator over a Yellowstone/Geyser gRPC stream — account and transaction updates pushed in real time. The service parses those updates and persists them: Postgres for the structured stuff (markets, swaps, positions, time-series), Redis as a cache layer, RocksDB for raw state snapshots, plus a search index. That's the durable truth. Everything downstream is a view onto it.
2. Fan-out: one subscription, many clients
Here's a constraint that shapes everything: you get one Geyser subscription, but many browsers want the data. So inside the process I fan it out with tokio::sync::broadcast channels — separate channels for market updates, user updates, and global events.
Two design choices I'd defend in any review:
- Fire-and-forget publish. Broadcasting an update never blocks the ingest write path. The thing that writes to Postgres must never wait on a slow websocket consumer.
- Explicit
laggedevents. When a slow client falls behind the broadcast ring buffer, it doesn't silently drop data and quietly drift out of sync. It gets alaggedevent and resyncs from the DB. The stream self-heals instead of lying to you.
3. Stream: Rust to the browser over SSE
The edges are Axum SSE endpoints — /stream, /markets/{addr}/stream, /users/{owner}/stream. Two things SSE forces you to get right on a real server:
- These routes deliberately bypass the global request-timeout layer. A request timeout is correct for normal handlers and fatal for a stream that's supposed to stay open forever.
- 15-second keep-alive pings, so idle proxies and load balancers don't decide the connection is dead and cut it.
4. The centerpiece: a two-connection pass-through
Now the fun part. The browser opens an EventSource to a same-origin Next.js Route Handler — which runs on a Cloudflare Worker at the edge.
There are really two chained TCP connections here: browser ↔ Worker, and Worker ↔ Rust. The naive implementation reads the upstream stream, parses each event, and re-emits it. Don't. The Worker should not consume and rebuild anything. It does this:
return new Response(upstream.body);
That's the whole idea. You take the upstream response body — a ReadableStream — and hand it straight back as your own response body. The two connections get spliced into a single byte pipe. Every data: chunk Rust writes flows straight through to the browser, untouched.
"But Workers run on a V8 isolate, they can't sit on a connection for an hour." Right — they can't sit busy for an hour. But piping is I/O-bound. CPU-time doesn't tick while bytes are merely flowing; wall-clock is uncapped as long as the client stays connected. The Worker is near-idle plumbing, not a busy process. That's the loophole, and it's a legitimate one.
The header gotcha that makes or breaks it
This is where people lose an afternoon. Any layer that thinks it's holding a complete body will try to buffer or compress it — and buffering an infinite stream means it hangs forever. The browser shows a skeleton loader that never resolves. The connection is "open." Nothing arrives.
The fix is to forbid every form of buffering on the way out:
cache-control: no-storex-accel-buffering: no- strip
content-encoding— no gzip on a stream that never ends - strip
content-length— there is no length; it's infinite
5. Fold deltas into React Query
On the client, each SSE delta is merged into the React Query cache — merge in place for the high-frequency events, invalidate-and-refetch for the rarer ones. No polling. The UI just updates as trades land.
What I took away
The lesson that stuck: the cleanest streaming layer is the one that does the least. The Worker's job wasn't to be smart — it was to get out of the way and let two TCP connections become one. The hard part wasn't the pipe; it was convincing every layer in between to stop trying to help.

Oleksandr Yusypenko
Senior Full-Stack + AI Engineer. Building in public — AI agents, LangGraph, production systems.
Related Posts
Jun 9, 2026
Three boundaries, one source of truth: sharing types across Rust and TypeScript
I almost unified my Rust backend and TypeScript frontend under one protobuf schema. The better answer was matching the tool to each boundary — tied together by one codegen rule.
Apr 18, 2026
How I Redesigned a Meal Planning App UX: From Panel to Modern Food-First Design
Melio worked, but looked like a back-office admin panel. Here's how I redesigned the entire meal-planning app UX across 47 routes — a research-driven, 9-wave approach that kept every feature intact.
Apr 8, 2026
Building an AI Content Engine for a Gov Contracting Platform
Government contracting is jargon-heavy and the content gap is huge. Here's the four-layer AI content engine I built for GovChime: a research agent, rubric scoring, an approval queue, and SEO-ready Next.js publishing.
Hiding an Internal API the Right Way: Why a Proxy Isn't Security