“How many concurrent calls can it handle?” is a reasonable business question and a poor load-test specification.

Two systems can both hold 500 active calls and behave completely differently. One reached that number gradually with long, quiet calls. The other received a burst of 80 new calls per second, negotiated multiple codecs, opened 1,000 AI streams and wrote every signalling event to the same database tables.

Concurrency is a snapshot. Voice systems usually fail during transitions.

Start rate and active calls are different limits

The first useful distinction is between calls per second and simultaneous calls.

Active-call capacity is mostly about resources held over time: RTP ports, memory, file descriptors, media buffers, WebSocket sessions and external AI connections.

Call-start capacity is about work concentrated into a short window: SIP transactions, DNS and carrier requests, SDP negotiation, authentication, database writes, container scheduling and application callbacks.

A platform may hold 1,000 stable calls but fail when 100 of them start together. Another may accept a burst quickly and then run out of CPU once most calls become active and begin transcoding or streaming audio to speech services.

The load model needs at least:

EXAMPLE

start rate        calls per second
answer rate       percentage and time-to-answer distribution
call duration     median and long-tail duration
media profile     codecs, packetization and transcoding
AI profile        STT, LLM and TTS sessions per call
event profile     signalling and application events per call

Without that shape, a concurrency number says very little.

The carrier is often the first queue

SIP providers enforce limits that are easy to confuse with application capacity. These can include CPS, concurrent channels, destination-specific routing rules and burst policies.

When the system exceeds them, the failure may arrive as a clear SIP response, a delayed transaction or an asynchronous originate failure. If the backend treats “command queued” as “call started,” rejected calls can remain visible as active long after the provider has already refused them.

This is not only a cleanup bug. Under load, stale calls consume campaign slots, agent capacity and retry budget. A small lifecycle leak becomes a throughput collapse.

The control plane therefore needs to correlate the originate request, FreeSWITCH job UUID, customer leg and eventual provider response. Every failed start must converge on a terminal call state, even if no normal channel event is ever created.

Media cost is not one number per call

RTP forwarding is cheap compared with transcoding, recording, resampling and AI media bugs. A benchmark that uses one codec and no auxiliary media work can dramatically overstate production capacity.

For each call, ask which of these are active:

  • RTP forwarding between carrier and browser;
  • SRTP encryption for WebRTC;
  • codec transcoding;
  • early-media buffering;
  • call recording;
  • voicemail or answering-machine detection;
  • resampling for STT;
  • one or more real-time WebSocket streams;
  • TTS playback mixed back into the channel.

These costs also change during the call. Early media may follow a different path from answered audio. A recording may begin at SIP 183 Session Progress. AVMD may be enabled only for some campaigns. TTS may create short CPU and bandwidth spikes rather than a stable average.

That is why host CPU averages are weak evidence. Per-process CPU, run-queue pressure, packet timing, media-thread saturation and event-loop lag reveal much more.

The Node.js service can fail while FreeSWITCH is healthy

In a backend-controlled architecture, FreeSWITCH may continue moving media while the Node.js control plane falls behind.

Common pressure points include:

  • a single ESL consumer parsing every channel event;
  • synchronous work in an event handler;
  • unbounded in-memory queues;
  • database updates for every low-level event;
  • large JSON payloads and verbose logs;
  • polling endpoints whose cost grows with active calls;
  • retry storms after a downstream timeout.

The dangerous symptom is not always high CPU. It can be increasing event age: FreeSWITCH emitted an event now, but the application processes it several seconds later. At that point the event may still be valid technically and stale operationally.

Measure queue depth and event lag directly. If an event includes its source timestamp, record the difference between source time and persistence time. A system that reports 20% CPU but processes hangups ten seconds late is already overloaded.

Persistence becomes a coordination problem

Calls touch several related records: the call itself, agent availability, customer status, legs, outcomes and event history. Under low load, independent updates appear harmless. Under concurrency, they compete and arrive out of order.

Typical failures include:

  • two terminal leg events trying to finalize one call;
  • an agent release racing with assignment to the next call;
  • a retry creating a second active call for the same contact;
  • event-history inserts overwhelming lifecycle updates;
  • dashboard queries scanning an ever-growing event table;
  • a hot campaign row serializing otherwise independent calls.

The answer is not to wrap the entire call in one database transaction. Calls live too long for that. The useful pattern is short atomic transitions with explicit invariants: one active call per ownership scope, compare-and-set state updates, idempotency keys and append-only events separated from the current-state projection.

AI services introduce a second capacity model

A Voice AI call is not only a SIP session. It may hold STT and TTS streams, make LLM requests and maintain a conversation state machine.

Each dependency has different limits:

  • maximum concurrent WebSocket sessions;
  • request rate and token rate;
  • audio bandwidth;
  • connection setup latency;
  • per-region capacity;
  • timeout and retry behavior.

If the telephony layer admits calls faster than the AI layer can accept them, the result is usually not a clean rejection. It is silence, delayed first audio, missing transcripts or a retry storm.

Admission control belongs before the customer experiences that failure. Reserve downstream capacity before starting expensive work, use bounded queues and define a degraded path. A call that receives a clear busy response is operationally better than a connected call with no functioning agent.

Average-load tests hide the interesting failures

A flat test with 300 identical calls can miss most production problems. I prefer a small matrix:

  1. Ramp test — find the stable active-call ceiling.
  2. Burst test — stress CPS, originate correlation and initial database writes.
  3. Long-call soak — expose leaks in channels, sockets, timers and event history.
  4. Churn test — rapidly answer, transfer and hang up calls to stress lifecycle races.
  5. Degraded-dependency test — slow STT, TTS, database or carrier responses deliberately.
  6. Mixed-media test — combine codecs, recording, early media and AI streams in realistic proportions.

The pass condition should include correctness, not only throughput. At the end of the run:

  • every call must be terminal or intentionally active;
  • agent and customer ownership must be consistent;
  • no channel should be orphaned;
  • event lag must recover after a burst;
  • media packet timing and loss must remain within bounds;
  • downstream retries must remain bounded.

The metrics I want during the test

System metrics are necessary, but call-path metrics make the failure explainable:

EXAMPLE

originate attempts / accepted / rejected
calls per second by carrier and response class
active agent legs / customer legs / bridged calls
time from command to first channel event
time from SIP progress to usable media
ESL queue depth and event processing lag
RTP packet loss, jitter and inter-packet delta by leg
STT/TTS connection count, setup time and errors
database transition latency and conflict count
terminal calls missing a final outcome

Correlate them by call ID and leg type. Aggregate charts tell you that something broke; correlation tells you which boundary broke first.

Scale boundaries, not just containers

Adding replicas helps only when ownership is clear. Multiple API pods consuming the same event stream without partitioning can duplicate work. Multiple media servers without deterministic call affinity can make control commands target the wrong node. A larger database does not fix unbounded event writes.

The architecture should state:

  • which component owns a call;
  • how commands reach the correct media node;
  • how events are partitioned and deduplicated;
  • where admission control happens;
  • which queues are bounded;
  • what degraded behavior looks like.

Kubernetes can restart and distribute processes. It cannot invent these rules.

High concurrency rarely reveals one dramatic bottleneck. It reveals every place where the system relied on timing, averages or implicit ownership. Once the workload is described as transitions across SIP, media, persistence and AI — instead of one concurrent-call number — capacity planning becomes engineering rather than guesswork.