Header Logo

Common Rust Concurrency Mistakes When Building Voice and Video Agents

Common Rust Concurrency Mistakes When Building Voice and Video Agents

Common Rust concurrency mistakes in voice/video agents: async locks, blocking I/O, backpressure, and cancellation pitfalls.

Introduction


Rust is a good fit for voice and video agents because the hard parts are mostly systems problems: concurrency, backpressure, low-latency I/O, and keeping a lot of state consistent while audio, transcripts, model responses, and video frames are all moving at once. The catch is that Rust will happily let you build a very fast, very deadlock-prone realtime system if you structure it poorly.


This post focuses on the concurrency mistakes I see most often when building voice or video agents in Rust, especially when WebRTC, audio pipelines, and async tasks are involved. By the end, you should be able to spot the common failure modes before they show up as missed audio frames, stuck sessions, runaway memory, or mysteriously laggy avatars.


Mistake 1: Treating async tasks like threads and ignoring ownership boundaries


The first mistake is assuming “spawn a task” means “everything is independent now.” In a voice agent, the opposite is usually true: the audio source, VAD, ASR, LLM, TTS, and video renderer are all coupled through shared session state. If you move that state across tasks carelessly, you end up cloning too much, locking too much, or capturing references that cannot live long enough.


The practical rule: keep session state small, explicitly share only what must be shared, and pass messages instead of references whenever possible. In Rust, channels are usually a better default than mutex-protected shared structs for realtime pipelines.


use tokio::sync::mpsc;

});
use tokio::sync::mpsc;

});
use tokio::sync::mpsc;

});


If you need shared mutable state, keep the lock scope tiny and never hold it across an .await. That single pattern causes a lot of deadlocks in async code, especially when an outbound API call or network write happens while the lock is still held.


use std::sync::Arc;

do_network_call().await;
use std::sync::Arc;

do_network_call().await;
use std::sync::Arc;

do_network_call().await;


Mistake 2: Blocking the async runtime with CPU work or synchronous I/O


Realtime agents are latency-sensitive, so it’s tempting to do “just a little bit” of work inline: JSON parsing, audio resampling, transcript post-processing, or image/video assembly. In Rust, if that code sits inside an async task and blocks the runtime thread, everything else in that executor suffers. Audio callback timing slips, websocket heartbeats drift, and the agent starts feeling sticky.


There are two common subcases:


  • CPU-bound work: token streaming, waveform analysis, frame encoding, lip-sync blending.

  • Blocking I/O: filesystem access, synchronous HTTP clients, database calls, shelling out to ffmpeg the wrong way.


The fix is architectural, not cosmetic. Use async-native libraries where possible, and offload actual CPU-heavy work to spawn_blocking or a dedicated worker pool. If you are processing audio frames at 20 ms cadence, you do not want one transcription post-process step to hold the reactor hostage.


tokio::task::spawn_blocking(move || {
}).await??;
tokio::task::spawn_blocking(move || {
}).await??;
tokio::task::spawn_blocking(move || {
}).await??;


For voice and video agents, this matters more than in a typical web service because the data is continuous. A single 200 ms stall is not “just a slow request”; it can be audible jitter or a dropped animation update.


Mistake 3: Using the wrong synchronization primitive for realtime fan-out


Many agent implementations start with one shared “session object” and a pile of Arc<Mutex<...>>. That works until you need to fan out events to multiple consumers: transcript handler, TTS queue, avatar renderer, logging, analytics, and maybe a UI websocket. Then the mutex becomes a bottleneck and a source of priority inversion.


For agent pipelines, prefer message passing over shared state. A good default is:


  • mpsc for point-to-point work queues.

  • broadcast for fan-out events that multiple consumers can observe independently.

  • watch for “latest value” state like session status or current speaking turn.


Be careful with unbounded channels. They are convenient and dangerous. In realtime systems, unbounded queues often hide backpressure until you are well past the point where a human notices lag. If your downstream can’t keep up, you want the system to shed load explicitly, not accumulate a three-minute backlog of obsolete audio frames.


use tokio::sync::mpsc;

});
use tokio::sync::mpsc;

});
use tokio::sync::mpsc;

});


The bounded channel forces you to confront throughput. That is usually good. If you need to drop, coalesce, or prioritize frames, do it deliberately and close to the source.


Mistake 4: Ignoring cancellation, shutdown, and task lifetimes


Voice agents are full of short-lived sessions: users disconnect, a websocket closes, the room ends, the avatar is replaced, or the browser tab disappears. If your spawned tasks ignore cancellation, you get zombie work: stale streams, duplicate callbacks, leaked buffers, and background tasks that keep burning CPU long after the user is gone.


Every spawned task should have a clear owner and a clear stop condition. In practice that means:


  1. Propagate a cancellation signal into every long-lived loop.

  2. Drop senders/receivers intentionally when the session ends.

  3. Ensure background workers exit when the session context is gone.

  4. Clean up WebRTC tracks, websocket connections, and temp files deterministically.


This is especially important when you have one task per modality. If the audio task exits but the video task keeps rendering, the user sees an avatar still “speaking” after the conversation has ended. If the reverse happens, you keep paying for a session that is functionally dead.


use tokio_util::sync::CancellationToken;

cancel.cancel();
use tokio_util::sync::CancellationToken;

cancel.cancel();
use tokio_util::sync::CancellationToken;

cancel.cancel();


Cancellation is not an implementation detail. It is part of the protocol of a realtime system.


How this shows up in a voice/video avatar integration


These concurrency issues become obvious when you add a synchronized talking face to a voice agent. The avatar pipeline has to stay aligned with the audio pipeline: when the agent starts speaking, the video must begin lip-syncing promptly; when the agent pauses, the face should stop moving; when the session ends, every task should terminate together.


If you are integrating a Rust-based voice stack through a plugin surface, the right mental model is still the same: event-driven, bounded queues, minimal shared state, and explicit cancellation. For example, the LiveKit Agents plugin path in the plugin repository is built around dropping the avatar into an existing agent pipeline rather than making you invent a new concurrency model from scratch. The point is not that the plugin removes concurrency problems; it is that it gives you a narrower surface where session timing, media flow, and teardown are already well-defined.


If you are using the broader integration guide, the Pipecat integration docs are a useful reference for how the avatar service fits into a pipeline. The same Rust lessons still apply underneath: never block the executor, never hold locks across awaits, and treat session shutdown as a first-class event.


Protoface surface choice and why it matters


Protoface exposes the avatar/session layer through surfaces that map cleanly onto these concurrency concerns. For a Rust developer, the most relevant one is usually the realtime plugin path, because it slots into an existing agent loop where audio and video are already being scheduled. If you are provisioning sessions directly, the REST API at docs.protoface.com documents the exact request fields, auth, and lifecycle; in production code, keep those API interactions behind a small async client so the rest of your agent never sees raw HTTP details.


use reqwest::Client;

.await?;
use reqwest::Client;

.await?;
use reqwest::Client;

.await?;


That example is intentionally generic: use the documented payloads and session fields from the docs, and keep the network boundary isolated from your media pipeline. Your audio/video code should consume a typed session result, not construct HTTP requests inline.


Conclusion


Most Rust concurrency bugs in voice and video agents come from the same few places: holding locks too long, blocking async tasks, using the wrong queue for the job, and forgetting that sessions end. Those mistakes are easy to make because realtime media systems create constant pressure to “just get the frame out.”


If you want a reliable agent, design for backpressure, cancellation, and message passing from the start. Keep state ownership narrow, push heavy work off the async runtime, and make teardown explicit. Then wire your avatar layer into that pipeline with a surface that matches your stack, whether that is a plugin, the REST API, or an SDK.


For implementation details and current examples, start with the docs, then use the relevant quickstart or plugin repository as a concrete reference. The main payoff is not just fewer bugs; it is that your agent stays responsive when it matters most.

Add a face to your AI.

No credit card needed.

Add a face to your AI.

No credit card needed.

Add a face to your AI.

No credit card needed.