Skip to content
12 min read

You cannot load-test a real-time system the way you load-test an API

Load-testing a real-time system with a request-rate tool measures the wrong thing entirely. Here is what actually breaks — the restart nobody scripts, the leak a thirty-minute run cannot see, and the memory limit that fires before the number you were trying to measure.

Vyacheslav Pankratov· Fullstack Developer
Cover art for “You cannot load-test a real-time system the way you load-test an API”
On this page

TL;DR: You cannot load-test a real-time system with a request-per-second tool, because there are no requests — there are N streams running continuously, and the interesting question is how many the box holds before something gives. Three things make it unlike API load testing: the spike that kills you is a simultaneous cold start, not peak traffic; the failure often builds over hours, so a thirty-minute ramp cannot see it; and the memory limit you set yourself fires before true demand, hiding the exact number you were trying to measure.

Somebody asks how many streams the server holds, and the reflex is to reach for the tool that answers that question everywhere else: generate load, ramp it, find the knee. It is the right instinct applied to the wrong shape of system, and it produces a number that is confidently wrong.

This is what we learned building the answer for an always-on video fleet after a capacity incident — what to generate, what to watch, and which of the failures only appear if you deliberately arrange for them.

Why doesn't a request-per-second tool work here?

Because the load is not made of requests. An API's load is a rate: arrivals per second, each one short. A real-time system's load is a population: N streams, each running continuously, each holding a decoder, an encoder, a file handle, a socket and a slice of memory for as long as it exists.

That changes what a test is. There is no throughput to ramp — there is a fleet size to increase and hold. The knee you are looking for is not "at what rate do latencies rise", it is "at what N does something die, and which thing dies first". Requests per second is not a smaller version of that question; it is a different question.

Two practical consequences follow immediately. Steady state is cheap and misleading — a fleet at rest looks comfortable, which is exactly what the dashboard showed before our incident. And the services that matter are the ones that scale linearly with the fleet: one persistent process per stream is the pattern to count, along with the transient processes that fire per event on top of it. Anything that does not grow with N is co-load, not capacity — and watch for the things added later that quietly do grow with it, such as a per-stream transcription sidecar.

Per-stream cost is the input to all of this, and it is worth measuring separately before you test a fleet at all — what one stream actually costs, on CPU and against a hardware ceiling that has no gradient.

What is the spike that actually kills you?

A simultaneous cold start — and it is the single most under-tested state in this class of system.

Configure N streams, then restart the service or reboot the machine. Every pipeline launches at once, and the transient goes far above steady state: N processes allocating, N connections negotiating, N decoders warming, all in the same few seconds. In our own incident this was the spike behind a fleet-wide outage, and the reason it went unnoticed for so long is specific and worth stating:

A gradual ramp does not reproduce it. If your test adds streams one at a time until it finds the knee, you have measured the steady-state ceiling and completely skipped the transient one — which is lower, and which happens in production every time anything restarts. Deployments cause it. Crashes cause it. A host reboot causes it. It is not an edge case; it is Tuesday.

So the test has to include the shape explicitly: bring up N at once, at several values of N, and watch the peak rather than the plateau. If your steady-state ceiling is 30 and your cold-start ceiling is 18, the honest capacity of that machine is 18.

Why does an overnight run find what a thirty-minute run cannot?

Because the failure mode that gets you is often a slope, not a level.

Our production incident did not arrive instantly. Resident memory crept — a slow accumulation in a long-lived process, invisible at any single moment and fatal across a day. A twenty- or thirty-minute ramp step samples a level; it cannot see a gradient that needs hours to become a number.

So a capacity test needs at least one fixed-N soak measured in hours, and what you read from it is not the peak but the slope: memory against time, disk against time. A flat slope at a high level is a machine you can size. A gentle upward slope at a comfortable level is a machine that will fail on a schedule you have not calculated yet.

The same applies to disk. Continuous recording fills storage at a rate that is trivially predictable and routinely un-modelled, and cleanup jobs are themselves periodic spikes. A soak shows both; a ramp shows neither.

The measurement your own limits prevent

This is the trap that cost us the most, and it is beautifully circular: you cannot measure a container's true demand from inside its own memory limit.

Set a limit, run the fleet up, and the kernel kills the container when its cgroup reaches that limit (cgroup v2 memory controller) — which tells you that the limit exists, not what the workload actually wanted. The number you are trying to discover is the peak demand, and the cap fires strictly before the workload reaches it. Every run produces the same answer: the limit. The workload's real appetite is never observed.

There is no clever way around it. To measure demand you have to raise or remove the cap for the measurement, on a stand rather than in production, and let the workload show you its peak. Then you set the limit from the measurement rather than from a guess — ours is measured peak × ~1.3, with alerting at about 80% of that, so the alert fires while there is still somewhere to go.

The general principle is worth carrying to any capped resource: a guardrail you set to a guessed value will report that guess back to you forever. If a limit was never derived from a measurement, every incident it causes will look like a workload problem rather than a configuration one.

Raise the limit before you measure, not after

A container killed at its memory limit reports the limit, never the demand — so a capacity run against a capped service produces the same answer every time and none of them are the one you wanted. Raise or remove the cap on the stand, let the workload show its peak, and set the limit from that number afterwards.

How do you build a fleet to test against?

You cannot point a load test at real cameras, and you should not want to: they are someone's production sources, their content varies hour to hour, and their availability is not yours to schedule. So build a synthetic fleet — and the design that matters is making each fake source almost free to run, or the rig competes with the thing it is measuring.

Ours is small enough to describe completely. A single container runs a couple of MediaMTX instances on separate ports, and a loop feeds each one a short clip that was normalised in advance — H.264, one-second keyframe interval, no B-frames — straight from disk:

ffmpeg -stream_loop -1 -re -i clip.mp4 -c copy -f rtsp rtsp://127.0.0.1:8554/main

Three deliberate choices in that one line, all documented in ffmpeg's own manual. -c copy means no encoding, so a fake camera costs almost nothing and you can run many; the clips are baked into the image so nothing is downloaded or transcoded at runtime. -re paces the file at real time, because a source that delivers as fast as the disk allows is not a camera and will not exercise the buffering that real ones do. And -stream_loop -1 makes it endless, which is the entire point of testing an always-on system.

The rest of the rig is mocks for whatever control surface the real devices expose — snapshot endpoints, status APIs — so the services under test take the same paths they take in production. If your system polls a camera for a JPEG, the fake fleet has to answer that poll, or you are testing a different code path than the one that runs.

Normalise the clips before you bake them. A rig that accidentally feeds MJPEG, or B-frames, or a long GOP, produces numbers about the wrong workload — and those are exactly the profiles that behave differently downstream, so the mistake looks like a finding.

What to record at the knee

The number is the least interesting output. Record the failure shape, because it changes what you build afterwards far more than the capacity figure does.

At saturation, ask specifically:

Question Why it decides something
Does the supervisor die, or one worker? same exhaustion, different blast radius — whole fleet versus one stream, and it decides whether you need per-stream isolation or a bigger box
Does anything degrade first? write stalls and rising latency often precede failure by minutes, and that is a better alert than any threshold on the resource itself
How long is recovery? a fleet that drops together and returns behind a retry interval outlasts the spike that caused it
What is aligned on one clock? rotation, upload and cleanup firing together turn steady load into a spike N times a day
  • What dies first — the supervisor or one stream? If the kernel kills the long-lived parent process, the whole fleet stops at once. If it kills one worker, one stream stops. Same resource exhaustion, completely different blast radius, and the difference decides whether you need per-stream isolation or just a bigger machine.
  • Is there degradation before the failure? Write stalls, page-cache eviction and rising latency frequently precede the hard failure by minutes. If so, that is your alert, and it is a better one than any threshold on the resource itself.
  • How long is recovery? A fleet that drops together and comes back gated behind a retry interval produces a synchronized outage far longer than the spike that caused it. Measure the recovery, not just the fall.
  • What is aligned? Periodic work — rotation, upload, cleanup — that fires on the same clock for every stream turns a manageable load into a spike N times a day. If a stagger exists to prevent that, the worst case is the run with the stagger switched off, and you should test it that way on purpose.

What the numbers are for

The output is a decision, so write the rule down before the test rather than after.

Ours: size by the worst realistic sustained peak with co-load, not the average — the average is what was running when production fell over. Pick a target fleet with growth headroom. Require that under a realistic busy peak — streaming plus recording plus event bursts plus periodic jobs — CPU and memory stay around 65–70%, because a machine with no swap has no buffer at all and the gap between "busy" and "dead" is one spike. Require that the synthetic worst case does not hard-fail at that target: graceful degradation is acceptable, fleet-wide loss is not.

A capacity number is also a cost number, and the two are usually owned by different people — what the fleet costs to run is worth putting beside what it fits before anyone signs for hardware.

Then set per-service limits from the measured peak with margin, and alerts below the limits. The sequence matters: measure, then limit, then alert. Doing it in the other order is how you end up measuring your own guess.

  1. Cold start at several Nthe ceiling a ramp never finds
  2. Fixed-N soak, hoursread the slope, not the level
  3. Raise the cap, find the peakon a stand, never in production
  4. Set limits from the measurementmeasured peak × margin
  5. Alert below the limitswhile there is still somewhere to go
The order is the whole method. Every step out of sequence produces a number that describes your configuration rather than your workload.

FAQ

Can we just use a normal load-testing tool? For the API surface around the system, yes — that part is request-shaped and behaves normally. For the media path, no: there is no request rate to ramp, and the tool will happily report numbers about a workload your system does not have. The two tests answer different questions and both are worth running.

How many synthetic sources do we need? More than your target fleet, because you are looking for the knee and the knee has to be inside the range you test. That is why the per-source cost of the rig matters so much: a fake camera that costs a core is a rig you cannot afford to run past your own capacity.

Is a staging box good enough, or does it have to match production? It has to match the shape — same services, same per-stream process model, same storage class — but the absolute numbers will not transfer between different hardware. Use the stand to find the failure modes and the ratios; re-measure the absolute ceiling on the hardware you will actually run.

We already know our steady-state ceiling. Is that enough? It is the higher of the two ceilings and therefore the less useful one. Test the simultaneous cold start before trusting any capacity figure, because that is the number production will meet on its next deployment.

The four things worth taking away

  1. Load here is a population, not a rate. N continuous streams, each holding resources for as long as it lives. Ramping requests per second measures a system you do not have.
  2. The cold start is the real ceiling. Everything launching at once goes far above steady state, a gradual ramp cannot reproduce it, and production performs it on every deployment.
  3. A limit you guessed will report your guess back to you. Peak demand cannot be measured through a cap that fires first — raise it on a stand, measure, then set the limit from the number.
  4. Record the failure shape, not just the number. Whether saturation kills one stream or the whole fleet, and whether anything degrades first, decides what you build next far more than the capacity figure does.

If you have never restarted your service with a full fleet configured and watched what the first ten seconds cost, that is the test to run this week. It is the cheapest one on this page and it is the one production runs for you anyway.


Sizing and instrumenting a real-time system that is already carrying load — including the failure modes that only appear on the restart nobody scripts — is what our Run & Scale engagement exists for: operating the live path and measuring it, rather than rewriting it.

The wider operating picture for an always-on fleet is on real-time video for streaming platforms.

Was this helpful?

// Build it

Running this in production?

Real-time video breaks in the places a demo never reaches — negotiation, weak networks, the transcoding bill. Tell us what breaks and where; we reply within a day with a concrete next step.