If anyone has ever worked with video streaming over the internet—i.e., used a classic media server operating on an RTSP-to-HLS model (or similar protocols)—then they know the kinds of problems that arise. As an example, let’s take two industry giants: Flussonic and Wowza. Flussonic’s documentation provides clear hardware specifications at which CPU usage will hit 100%: 250 cameras at a bitrate of 2 Mbps on a Xeon E3-1230v5 3.4 GHz with 32 GB of RAM. Wowza does not provide equally specific examples, but if we extrapolate a bit, roughly 350–400 cameras at 2 Mbps on the same hardware would also max out the CPU.
Let me add one caveat: I understand that this hardware is fairly old. But around 80% of small and medium-sized businesses still run on something in this range—at least those that do not use cloud infrastructure or rent hardware in a data center. So, at first glance, you might think: well, that’s just the workload, what can you do? The problem is that, at any given moment, nobody may actually be watching these streams. The hypothetical security guard is scrolling through their phone, drinking coffee—and meanwhile, the server continues 24/7 to package raw frames into HLS segments and write the archive, heating up the server room.
It is simply a waste of resources when they are not actually needed. And in an era where hardware costs money, these things are worth paying attention to.
The solution seems obvious, and many people are already familiar with it: On Demand mode. No viewers—shut down the camera. A viewer appears—serve the HLS stream. At first glance, everything seems perfect. Problem solved, everyone can go home.
But this is where the main problem with classic On Demand appears: as soon as there are no viewers and the camera is shut down, archive recording stops as well. For any security system, that is a major red flag.
While developing RUSEON Core, I spent a long time wrestling with this architectural trade-off, because the main goal of the project is to consume as few resources as possible while delivering the maximum benefit. Eventually, I came up with a feature that I call Lazy Muxing 2.0 (sounds pretty good, doesn’t it?).
The idea is simple: the core runs continuously, the archive is always recorded, and only the heaviest component—the HLS muxer—goes to sleep. Now, let me explain how this was implemented and what pitfalls can be found along the way.
Low-Copy approach
A traditional video server pipeline is essentially monolithic. But as I mentioned in previous articles, RUSEON is architecturally split into independent components. These are not microservices, nor is this an attempt to move everything into separate processes. The point is specifically to isolate modules within a single process—meaning that no module should drag another one along with it, much less affect its lifecycle.
If we look under the hood (ring.go), we can see an isolated goroutine continuously pulling the RTSP stream. Frames are placed into a ring buffer, and from that point onward a low-copy approach is used.
Disk recording (recorder.go) is simply a subscriber to that buffer. It reads pointers to memory and writes the bytes to disk. That’s it. It does not care in the slightest whether anyone is watching or not. The archive is recorded 24/7.
// internal/recorder/recorder.go (simplified)
func (r *Recorder) run() {
// Subscribe to the RingBuffer (Data Plane)
reader := r.ringBuffer.NewReader()
defer reader.Close()
for {
// Read a pointer to the frame without copying the actual bytes (Low-Copy)
frame := reader.Read()
if frame == nil {
break
}
// Write NAL unit bytes to disk
r.writeToDisk(frame)
}
}
The heavy HLS muxer (muxer.go), on the other hand, is not a mandatory part of the pipeline. It simply sits there like a “lazy plugin” and attaches to the same buffer only when there is an actual consumer.
No HTTP requests for the playlist? The muxer does not even exist in memory.
This creates a simple principle:
Archive is permanent. HLS is temporary.
This separation makes it possible to avoid wasting server resources on creating and running an HLS stream for a camera that nobody is currently watching.
So how is this different from what Flussonic does?
A fair question arises here: is this actually something fundamentally new? No.
For example, Flussonic has long implemented separation between stream ingestion, DVR, and delivery.
In their documentation, Flussonic describes its approach as just-in-time packaging: the server receives the stream once and can immediately package it on the fly into different formats—HLS, DASH, RTMP, and so on. At the same time, the DVR, which is their archive, operates as a separate module that continuously records the stream.
So, roughly speaking, their architecture looks like this:
┌──────────────► DVR / Archive
│
Camera ──► Ingest
│
└──────────────► JIT Packaging
│
├── HLS
├── DASH
└── other protocols
However, there is an important nuance when it comes to the term On Demand.
In Flussonic, ondemand primarily refers to the lifecycle of the input stream itself: if there are no viewers, the source is shut down, and when a request appears, the stream is started again. This is more about saving resources on ingestion and traffic.
Our implementation takes a somewhat different approach.
The stream itself and recording never depend on whether there is a viewer. The RTSP stream is always being ingested, the ring buffer remains active, and the recorder writes the archive 24/7.
On-demand behavior applies only to the heaviest part of the pipeline—the HLS packaging.
In other words, our architecture looks roughly like this:
No viewer
↓
Do not create the HLS muxer
↓
Ingest keeps running
↓
Archive keeps running
Based on that, it would be more accurate to say that Flussonic’s On Demand manages the lifecycle of the source itself—the RTSP connection—while Lazy Muxing in our implementation exclusively manages the lifecycle of the heavy pipeline component, the HLS packaging layer, while keeping the source RTSP stream hot and active for recording.
At the same time, Flussonic’s overall approach is, of course, much broader than our implementation. It is a full-featured system for delivering a single source through multiple output protocols.
Our task is much narrower: not keeping HLS multiplexing active for cameras that nobody is currently watching.
This allows us to tightly tie the lifecycle of the muxer to the presence of an actual HLS viewer.
Late viewers and low-latency startup
Another problem with On Demand is its relatively slow “wake-up” time.
The client presses Play—or autoplay is enabled, but they have just opened the browser or player—and... they are staring at a spinning loader for 5–15 seconds.
Why?
The issue lies in the HLS protocol itself. An HLS segment must begin with an I-frame, or keyframe.
As a result, the muxer wakes up and simply waits for the camera to send the next keyframe. If the camera’s GOP is configured for 2, 3, 4, or 5 seconds, the client will have to wait that long, plus the buffering time of the player itself.
In the worst cases, the total time from the first click to the appearance of an image can approach 30 seconds.
Nobody likes latency. HTTP/3 is on the horizon, and one of its advantages is precisely reduced latency.
So what did we do to work around this?
When the lazy muxer starts, it does not wait for new frames at all. Instead, it performs a dump of the RingBuffer. Since the buffer is circular—and we keep roughly 30 frames in it—it is guaranteed to contain the previous keyframe.
The muxer immediately extracts this historical GOP, assembles the first .ts segment, and serves it to the player.
The viewer gets an image almost instantly, aside from the player’s own buffering, which obviously does not disappear.
// internal/hls/muxer.go
func (m *Muxer) run() {
// Get a channel where the RingBuffer will IMMEDIATELY provide
// the historical GOP
reader := m.ringBuffer.NewReader()
defer reader.Close()
for {
frame := reader.Read()
if currentBuf == nil {
// Ignore everything until the first historical I-frame from the dump
if !frame.IsKeyFrame {
continue
}
// Instantly generate the first .ts segment!
tsWriter = mpegts.NewWriter(currentBuf, tracks)
}
// ...
}
}
The nuance here is that the video starts roughly a second behind real time, but for the viewer, the image appears almost immediately.
There are limitations, of course.
If your cameras—usually extremely cheap Chinese PTZ cameras—produce a keyframe once every 10 seconds, or God forbid even less frequently, then keeping such a large buffer in RAM becomes memory suicide.
Our buffer is tuned for a relatively small number of frames and frequent keyframes.
We are not trying to compensate for poor camera configuration by endlessly increasing the buffer size.
How do we know that the viewer has left, or: a mini watchdog
With WebRTC, everything is straightforward. The socket disconnects, the viewer is gone.
With HLS, however, things are a little more complicated.
It is plain HTTP. The player simply requests the .m3u8 file every few seconds.
At first, there was an idea to implement sophisticated analytics based on TCP sessions, but in the end, it was decided not to overcomplicate things.
Why reinvent the wheel when you can avoid it?
Instead, we implemented a very simple, brute-force, and straightforward watchdog.
I’ll just leave the code here:
// internal/stream/stream.go
// 1. Called on every HTTP request to the .m3u8 playlist
func (s *Stream) WakeUpHLSMuxer() *hls.Muxer {
s.muxerMu.Lock()
defer s.muxerMu.Unlock()
s.lastHLSRequest = time.Now() // Update the timer
// If the muxer is sleeping, start it
if s.hlsMuxer == nil {
s.hlsMuxer = hls.NewMuxer(s.ID, s.ringBuffer)
}
return s.hlsMuxer
}
// 2. Watchdog goroutine running in the background
func (s *Stream) lazyHLSWatchdog() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
s.muxerMu.Lock()
// If there has been no activity for more than 60 seconds,
// gracefully shut down the muxer
if s.hlsMuxer != nil && time.Since(s.lastHLSRequest) > 60*time.Second {
s.hlsMuxer.Stop()
s.hlsMuxer = nil // Release memory and CPU
}
s.muxerMu.Unlock()
}
}
The numbers
Words are great, but let’s look at the metrics.
A lot of features look great on paper, and then in production you suddenly discover goroutine leaks.
For testing, I wrote a utility that simulates 100 cameras running at 30 FPS.
That is 3,000 frames per second.
Two scenarios were compared:
- 100 active HLS viewers + recorder
- 100 sleeping HLS viewers, with only the recorder running
The memory graph in the second scenario is a perfect sawtooth. The average baseline is 1.3 GB.
And what happens to CPU usage?
It drops by almost 5×.
The core handles 3,000 FPS for continuous archive recording without even breaking a sweat, because it no longer has to perform multiplexing.
Sleeping viewers genuinely free up server resources instead of simply sitting there as dead weight.
In essence, you get stream density similar to a bare RTSP relay, while still retaining the convenience of web players and full archive recording.
========================================
RUSEON Core Capacity Test
Cameras: 100 | Viewers: 100 (Sleeping) | Duration: 60s
========================================
[*] Starting cameras and pipelines...
[*] Load test running...
Memory: 1056 MB (Alloc) | GC Pauses: 10
Memory: 1660 MB (Alloc) | GC Pauses: 11
Memory: 1241 MB (Alloc) | GC Pauses: 12 <-- GC cleanup (Perfect sawtooth)
...
Memory: 2471 MB (Alloc) | GC Pauses: 14
Memory: 1355 MB (Alloc) | GC Pauses: 15
========================================
RESULTS
Frames Ingested: 181 800
Average Ingest FPS: 3026.68
Final Memory Alloc: 1517 MB (Baseline)
========================================
Top comments (0)