tides_screen: The Rust repo that pretends to stream video and actually stress-tests WebSockets

A tiny binary, a semaphore, and 10,000 queued tasks. This is a clean look at how Rust can simulate load without losing control of it.

7 min read • View on GitHub • More from dashn9

A wide control room filled with thousands of tiny connection nodes feeding into one narrow gate. The scene explains the repo's central idea: a huge backlog can exist without becoming huge simultaneous pressure.
The surface story is streaming. The real story is a gate that keeps concurrency bounded.
Key Takeaways

The repo looks like a player. The code acts like a pressure test.

On the surface, tides_screen reads like a streaming tool. The name points one way, but the code points somewhere more useful: straight at bounded concurrency.

There is no public launch thread, no interview trail, and no verified quote to lean on here. That thin public footprint is actually helpful, because the code becomes the only honest narrator.

And the code is blunt. It spawns 10,000 tasks, then uses a semaphore to keep only a small number active at once. That is the whole move, and it is what makes the repo interesting.

A close-up of two hands at a workbench, one feeding an endless stack of identical cards into a funnel while the other counts tokens at the opening. The image clarifies the difference between backlog size and active concurrency.
A huge queue is not the same thing as a huge live workload.

The real product is bounded fan-out

The heart of `main.rs` is `Arc`. The loop can fan out wildly, but the permit count decides how much work is actually allowed to breathe at once.

An interactive map of how 10,000 queued tasks become only a handful of live WebSocket sessions.

// simplified from src/main.rs
let permits = Arc::new(Semaphore::new(25));

for _ in 0..10_000 {
    let permits = Arc::clone(&permits);
    task::spawn(async move {
        let _permit = permits.acquire().await.unwrap();
        let _client = connect_to_websocket().await.unwrap();
    });
}

That pattern is easy to miss if you only look at the task count. Ten thousand spawned futures sound like chaos, but the semaphore turns them into a queue with a fixed service rate. The machine stays calm because the limit is explicit.

This is the difference between simulating scale and actually unleashing it. The repo wants backlog, not collapse.

The WebSocket layer stays small on purpose

The networking side is split into a tiny wrapper and a tiny protocol type. `WSClient` hides `WebSocketStream>`, and `WSMessage` keeps the frame types in one place instead of spreading tungstenite details through the rest of the app.

That boundary matters. Once the rest of the program speaks in `WSMessage`, the transport layer can change without forcing every caller to learn tungstenite's shape.

// simplified from src/websocket/message.rs
pub enum WSMessage {
    Text(String),
    Binary(Vec<u8>),
    Ping(Vec<u8>),
    Pong(Vec<u8>),
    Close,
}

impl From<Message> for WSMessage {
    fn from(msg: Message) -> Self {
        match msg {
            Message::Text(text) => WSMessage::Text(text),
            Message::Binary(bytes) => WSMessage::Binary(bytes),
            Message::Ping(bytes) => WSMessage::Ping(bytes),
            Message::Pong(bytes) => WSMessage::Pong(bytes),
            Message::Close(_) => WSMessage::Close,
            Message::Frame(_) => panic!("unexpected frame"),
        }
    }
}

That is not a fancy abstraction. It is a clean one. The repo only needs a narrow slice of WebSocket behavior, so it keeps the slice narrow.

The unfinished edges are part of the lesson

The repo is plainly experimental. The localhost endpoint is hardcoded, the behavior is sparse, and the versioning reads like a skeleton rather than a finished product.

That weakness is also the value. Because the app is not buried under features, the concurrency pattern is easy to see in isolation.

A split-panel comparison where the left side shows a knot of threads trying to open too many connections at once, while the right side shows the same stream passing through a narrow gate and emerging in orderly lines. The image explains why bounded fan-out is safer than unbounded spawning.
The same workload, two very different shapes of pressure.
ModelWhat happensStrengthFailure modeBest use case
Sequential clientOne connection runs at a time.Simplest mental model.Too slow and too artificial for pressure testing.Smoke tests and basic sanity checks.
Unbounded fan-outEvery task starts at once.Maximum pressure in the shortest time.Memory, file descriptors, and network stack can collapse.Tiny synthetic demos.
Semaphore-gated fan-outMany tasks queue, few run at once.Predictable resource use with a huge backlog.You can still bottleneck if the permit count is wrong.Controlled load simulation.
Purpose-built load toolThe harness handles metrics, scheduling, and reporting.Operationally complete and easier to benchmark.More setup, more surface area, less tiny-code clarity.Production-grade testing.

So the real comparison is not fast versus slow. It is controlled pressure versus uncontrolled pressure. `tides_screen` sits in the middle, which is why it is useful as a specimen even if it is not yet a polished product.

What bounded concurrency beats, and what it does not

If you need to push a backend hard without letting the test itself become the problem, this pattern earns its keep. It is better than a naive spawn storm, and it is better than a sequential loop when the point is to create realistic contention.

It is not the right answer if you need dashboards, distributed runners, historical reporting, or a full test plan. For that, use a dedicated load-testing platform. `tides_screen` is smaller than that, and that is why the idea reads so cleanly.