A platform holding 500 calls tells you very little about how it got there. It might have accumulated long calls over ten minutes. It might have just accepted 80 new calls in a second, negotiated codecs and opened hundreds of AI connections.
Both dashboards say “500 active calls.” The second system has done a lot more work in the last few seconds.
When I plan a voice load test, I ask for the call-start rate and the media workload alongside concurrency. Otherwise it's too easy to produce a reassuring number for a workload the product will never see.
Start rate and active calls are different limits
An active call holds resources: RTP ports, buffers, sockets, memory and possibly STT or TTS sessions. Starting a call concentrates other work into a short interval: SIP transactions, authentication, SDP negotiation, database writes and provider connections.
A server that holds 1,000 established calls can still struggle when 100 start together. A server that accepts starts quickly can later saturate when those calls begin transcoding and recording.
I write the workload down before running it:
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
Call duration matters to both capacity and test length. Short calls exercise cleanup repeatedly; long calls expose resources that never get released.
The carrier may reject work the application thinks it accepted
Carriers can limit calls per second, concurrent channels and bursts, sometimes differently by destination. A rejection may arrive after FreeSWITCH has accepted an asynchronous originate command.
If the backend records “command queued” as “call started,” a rejected attempt can remain active in the product. It then occupies a campaign slot or keeps an agent unavailable. Enough stale records can stop useful work while the media server still has capacity.
Correlate the originate request, FreeSWITCH job UUID, customer leg and provider response. Failed starts need a terminal product outcome even when no normal customer channel is created.
Count the work done to each audio stream
Forwarding compatible RTP is a different workload from transcoding, recording, resampling and streaming audio to AI services. A benchmark with one codec and no recording is only evidence for that configuration.
For the production mix, include the features actually used: SRTP, codec conversion, early-media buffering, recording, answering-machine detection, STT streams and TTS playback. Their cost changes during a call. Recording can begin at 183 Session Progress; AVMD may run only for selected campaigns; TTS generates bursts of work when replies start.
Watch per-process CPU, run-queue pressure and packet timing as well as host averages. A media thread that can't keep its cadence can produce bad audio while an aggregate CPU chart looks comfortable.
FreeSWITCH can be healthy while Node.js falls behind
The media server may keep moving audio while the backend processes events more and more slowly. Possible bottlenecks include a single ESL consumer, synchronous handlers, database writes on every event, verbose logging and unbounded queues.
The metric I want here is event age. Record when FreeSWITCH emitted the event and when the application processed and persisted it, accounting for clock differences. Ten-second-old hangups are a problem even if the backend reports 20% CPU.
Also measure queue depth and recovery after a burst. A queue that drains tells a different story from one that grows until the process runs out of memory.
Polling can add pressure at exactly the wrong time. Check whether a dashboard request gets more expensive as active calls and event history grow. Retry storms can multiply that load when responses become slow.
Database contention shows up as incorrect ownership
A call updates more than one record: agent availability, customer status, legs, outcome and event history. Under load, those writes overlap.
Two legs may try to finalize the same call. Releasing an agent can race with assigning their next call. A retry can create a second attempt for one contact. A frequently updated campaign row can serialize work that should otherwise be independent.
Keep transitions short and atomic. Use compare-and-set state updates and idempotency keys where they protect a concrete invariant. Keep historical event writes from blocking the current-state projection. Holding one database transaction open for the entire conversation would create a different problem.
AI has its own capacity limits
Telephony capacity doesn't reserve an STT connection or an LLM request. Those services have separate concurrency, request-rate, token-rate, bandwidth and regional limits.
If the platform accepts calls faster than the AI path can serve them, callers may get silence after answer. Retries then add more requests to an already saturated dependency.
Admission control should account for downstream capacity, with bounded queues and a defined fallback. For outbound work, pacing the next originate is often possible. For inbound work, decide whether the caller should hear an announcement, reach another destination or receive a rejection. That decision belongs in the call flow before overload happens.
The test matrix I use
I want several different runs, each with a specific question:
- Ramp: how many established calls can the real media profile sustain?
- Burst: can the system accept the required CPS and finalize rejected starts?
- Soak: do channels, sockets, timers or records accumulate over time?
- Churn: do rapid answers, transfers and hangups break lifecycle rules?
- Slow dependencies: what does the caller experience when STT, TTS, the database or carrier falls behind?
- Mixed media: do the results hold with the production mix of codecs, early media, recordings and AI streams?
After each run, account for every attempt. Calls should be terminal or deliberately still active; agent ownership should agree with that state; channels should not be orphaned. Check that event lag recovers and retries stay bounded.
During the run, these measurements help explain failures:
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
Keep call IDs and leg types available when moving from a chart to an individual failure.
Before adding replicas
Check how a command finds the FreeSWITCH node that owns the call, how workers divide the event stream, and how duplicates are handled. Replicas consuming the same events can duplicate work. A healthy media node can still be the wrong destination for a transfer command.
Those ownership rules, admission limits and queue bounds determine whether another replica helps. Once they are explicit, a load test can identify which resource to add and whether it actually improves the failing workload.