Most “language X vs language Y” articles end with “it depends.” This one does too, but we’ll show you exactly what it depends on — because we run both Elixir and Rust in production, handling real live streams, and the split isn’t arbitrary. Each language owns the workload it was born for.
TurboKast’s architecture has two planes: a control plane (Elixir/Phoenix) that handles auth, UI, stream orchestration, and billing, and a media plane (Rust) that handles the real-time video and audio processing. They deploy independently, scale independently, and fail independently — which is the whole point.
Here’s why we didn’t just pick one.
What makes live streaming infrastructure different?
Live streaming sits at the intersection of two very different engineering problems. The first is coordination: authenticating users, managing stream configurations, routing streams to destinations, updating dashboards in real time, processing webhooks, handling billing. This is classic web application territory — high concurrency, lots of I/O, moderate compute.
The second is media processing: receiving raw video and audio, demuxing transport streams, extracting codec frames, remuxing for different output formats, generating preview segments, and delivering to multiple destinations simultaneously. This is byte-level work — high throughput, latency-sensitive, CPU-intensive, and unforgiving of mistakes. A misaligned packet boundary doesn’t throw an exception. It corrupts every downstream viewer’s feed.
Most languages are good at one of these. Very few are good at both. We tried using one language for everything. It didn’t work.
Why Elixir for the control plane
Elixir runs on the BEAM virtual machine — the same runtime that powers Ericsson’s telecom switches, which famously achieve nine nines of availability. The BEAM’s concurrency model is genuinely different from anything in the Rust, Go, or JVM ecosystems, and those differences matter enormously for a streaming control plane.
Lightweight processes for massive concurrency
The BEAM spawns lightweight processes (not OS threads) that cost roughly 2KB each. You can run millions of them. Each stream session, each WebSocket connection, each PubSub subscription, each background task — they’re all separate processes with isolated memory and independent garbage collection.
When a user opens their dashboard and watches their stream status update in real time, that’s a Phoenix LiveView process holding a WebSocket, subscribed to events from the rest of the system. The BEAM juggles all of this without breaking a sweat because it was designed for exactly this kind of workload — millions of concurrent, mostly-idle processes doing small amounts of work in response to events.
Fault tolerance that actually works
OTP supervision trees are Elixir’s secret weapon. Every process runs under a supervisor that defines what happens when it crashes. A chat session crashes? The supervisor restarts it. An OAuth token refresh fails? Retry with backoff. A webhook handler panics? The request fails, the process restarts, the next request succeeds.
This isn’t theoretical. In production, individual processes crash regularly — bad API responses, timeout races, malformed payloads. The system stays up because crashes are scoped. A single user’s chat session crashing doesn’t affect anyone else’s stream. Compare this to a thread panic in most runtimes, which often takes down the entire service.
Phoenix LiveView for real-time UI
Our entire dashboard — stream status, destination health, chat, admin panels — runs on Phoenix LiveView. Server-rendered HTML, pushed over WebSocket, with no JavaScript framework. When a stream goes live, every connected dashboard updates within milliseconds.
This matters for developer velocity. Adding a new real-time feature (say, showing per-destination bitrate) means writing an Elixir function and a template. No REST endpoint, no React component, no state management library, no WebSocket protocol to design. The full-stack feedback loop is minutes, not hours.
Where Elixir falls short
Elixir is not suited for processing media bytes. BEAM processes communicate by copying data between their isolated heaps. Passing a transport stream packet through a pipeline of processes means copying it at every stage. At typical streaming bitrates, that’s thousands of packets per second per stream — the copying overhead becomes the bottleneck.
The BEAM’s scheduler is preemptive with reduction counting, which is brilliant for fairness across millions of lightweight processes but terrible for sustained CPU-bound work like video remuxing. A long-running computation blocks a scheduler thread, degrading latency for every other process on that scheduler.
We learned this the hard way. Our first media pipeline was pure Elixir. It worked for a single stream. At several concurrent streams, latency spiked. Add a few more, and it fell over.
Why Rust for the media plane
Rust gives us three things that no managed-language runtime can: zero-copy data paths, predictable latency, and minimal memory overhead. For media processing, these aren’t nice-to-haves — they’re table stakes.
Zero-copy media pipelines
A media pipeline ingests a stream, demuxes it, and fans out to multiple destinations simultaneously. The same video keyframe needs to reach every output — multiple RTMP destinations, the preview segmenter, potentially more — all without copying the underlying bytes.
Rust’s bytes::Bytes type makes this practical. It’s a reference-counted byte buffer that supports zero-copy slicing. One allocation serves every downstream consumer. In a language with garbage collection, this pattern either doesn’t exist or requires unsafe escape hatches. In Rust, it’s the standard library.
Predictable latency without GC pauses
Live streaming has a hard real-time constraint: if you add more than a few hundred milliseconds of processing latency, viewers notice. Buffering spinners kill engagement. GC pauses of even 10ms are visible when they stack up across a pipeline with multiple stages.
Rust has no garbage collector. Memory is freed deterministically when values go out of scope. A Rust media pipeline’s latency is bounded by the work it actually does (demuxing, remuxing, network I/O), not by when the runtime decides to clean up memory. This predictability is the difference between “usually fine” and “always fine.”
Memory efficiency at scale
A Rust media process handling a live stream uses a fraction of the memory a managed-language equivalent would need. The difference is typically 3-5x due to GC heap overhead, object headers, and runtime metadata.
This directly affects infrastructure cost. When media machines scale to zero between streams, the memory ceiling determines your instance size, which determines your per-second cost. Rust’s frugality means smaller instances and more streams per machine.
Tokio for async I/O
Media processing is both CPU-intensive (demuxing, remuxing) and I/O-intensive (receiving streams, publishing to destinations, uploading segments). Rust’s tokio runtime handles both efficiently. Network I/O is async and non-blocking. CPU-bound work runs on dedicated threads. Structured concurrency primitives keep pipeline stages coordinated, and cancellation tokens propagate shutdown cleanly through every stage — no data loss, no corrupted segments.
Where Rust falls short
Rust is slower to develop in. Compile times are measured in tens of seconds for incremental builds. The borrow checker catches real bugs but also rejects valid designs that require refactoring to satisfy. Adding a new API endpoint in Rust takes significantly longer than in Elixir.
Rust has no equivalent to Phoenix LiveView. Building a real-time dashboard would require a separate frontend framework, a WebSocket protocol, state synchronization — weeks of work that Phoenix gives you for free.
Database-heavy CRUD is more verbose. Rust’s database libraries are capable but require more boilerplate than Elixir’s Ecto. For a control plane that’s 80% “read from database, transform, render HTML,” Elixir is dramatically more productive.
How the two services communicate
The boundary between an Elixir control plane and a Rust media plane should be deliberately simple. For any hybrid architecture like this, the key principles are:
- Shared database as the source of truth for configuration and session state
- Event channels for real-time coordination (stream started, stream stopped, configuration changed)
- HTTP for health checks and point queries
- Tokens for authentication between services
No RPC framework, no message queue, no shared process memory. Keep the integration boundary boring — boring means fewer failure modes. The two services should be able to restart independently without corrupting each other’s state.
When to use which: a decision framework
If you’re building streaming infrastructure and choosing a language, here’s how we’d think about it:
Choose Elixir (or another BEAM language) when:
- The workload is I/O-bound with high concurrency (WebSockets, webhooks, PubSub)
- Fault isolation matters more than raw throughput
- You need real-time web UI (LiveView is unmatched)
- Developer velocity matters — you’re iterating on features weekly
- The data flow is “receive event, query database, broadcast update”
Choose Rust when:
- The workload is CPU-bound or latency-sensitive (media processing, transcoding)
- Zero-copy data paths matter for throughput
- Memory efficiency directly affects infrastructure cost
- Correctness at the byte level is critical (protocol implementations, codec handling)
- The data flow is “receive bytes, transform bytes, send bytes”
Consider using both when:
- Your system has clearly separable planes with different performance profiles
- You can define a clean integration boundary (shared database, HTTP, message queue)
- Your team has (or can develop) expertise in both ecosystems
- Independent deployment and scaling provide real operational benefits
The hybrid approach has overhead — two build systems, two deployment pipelines, two sets of libraries to maintain. It’s only worth it when the workloads genuinely have different requirements. A control plane serving dashboard users and a media plane processing live video streams are fundamentally different problems.
The operational reality
Running two languages in production means two sets of operational concerns, but that’s also the strength. Deploy them independently — shipping a UI fix doesn’t touch the media plane. Scale them on different axes — web traffic and media load have very different curves. And when something fails, the blast radius is contained: a media plane crash doesn’t take down your auth and billing, and a control plane restart doesn’t drop active streams.
This failure isolation is the most valuable property of the split. It’s the reason we maintain two build systems, two deployment pipelines, and two on-call runbooks. The operational overhead pays for itself the first time one service crashes and the other keeps running.
Should you do this?
Probably not — at least, not on day one. We started with a monolithic Elixir application and extracted the media plane into Rust only after hitting concrete performance ceilings. The extraction was straightforward because we’d already structured the Elixir code with clean module boundaries.
If you’re building a streaming platform, start with one language. Hit real limits. Then extract the component that’s bottlenecked. A clean integration boundary is simple enough that the extraction isn’t painful — as long as your original code has good separation of concerns.
If you’re choosing between Elixir and Rust for a streaming project from scratch: Elixir for the 80% that’s web application, Rust for the 20% that’s byte processing. Or if multi-streaming is your goal and you’d rather stream than build infrastructure, you could skip the build entirely and point OBS at TurboKast instead.