# Load test report

Date: 2026-11-09. Target: **50 concurrent calls on one FreeSWITCH node**. Result: **met** (50/50),
and 100 concurrent calls with recording also passed.

## Environment
Everything on one developer machine: Apple M6 (arm64), Colima VM with 6 vCPU / 10 GB, all services
in Docker on one bridge network — the platform (api, controller, FreeSWITCH 1.10.12, Postgres,
Redis, S3) **and** the load generators (simulated carrier + simulated agents, which are another
FreeSWITCH and used about as much CPU as the platform's FreeSWITCH). A production VM runs only the
platform, so these numbers are conservative.

## Method (`scripts/loadtest.sh`)
1. **Click-to-call, full stack.** N simulated agents register like phones (the simulator's
   `agentsim` profile, one gateway per agent). N `POST /v1/calls` are sent at once. Every call
   runs the whole path: API policy and call record → queue → controller → ESL originate → agent
   leg answered with music offered as **Opus** → carrier leg via the simulated G.711 trunk (echo)
   → bridge, events, timeline. FreeSWITCH therefore **transcodes Opus↔PCMU on every call**, like
   with WebRTC agents. Calls are held, then hung up via the API. Media is verified per call from
   the RTP packet counters stored in the call timeline.
2. **SIPp, carrier ingress.** SIPp (`sipp/sipp:3.8`, built-in UAC) acts as the carrier: 2×N
   calls to a DID at 10 calls/s, at most N concurrent, 45 s each; the DID routes to a simulated agent.
3. Resource usage sampled with `docker stats` every 3 s (CPU 100% = one core).

## Results

### 50 concurrent click-to-calls, 60 s hold
| Metric | Result |
|---|---|
| Accepted / answered / completed | 50 / 50 / 50 |
| Peak concurrent answered calls | 50 (100 FreeSWITCH sessions) |
| Calls with two-way media | 50 (Opus ↔ PCMU) |
| `POST /v1/calls` latency | p50 158 ms, p95 161 ms (50 requests in one burst) |
| Time to answer (both legs) | p50 1.1 s, p95 2.1 s — dominated by deliberate originate pacing (20/s) |
| Hangup causes | NORMAL_CLEARING × 50 |
| Peak CPU | FreeSWITCH 0.40 core, api 0.26, controller 0.12, Postgres 0.18 |
| Peak memory | FreeSWITCH 83 MiB, api 98 MiB, controller 195 MiB, Postgres 90 MiB |

### 100 concurrent click-to-calls with recording, 60 s hold
| Metric | Result |
|---|---|
| Accepted / answered / completed / two-way media | 100 / 100 / 100 / 100 |
| Peak FreeSWITCH sessions | 200 |
| Recordings | 100 uploaded (stereo, agent leg) |
| Peak CPU | FreeSWITCH 1.2 cores, controller 0.9 (uploads), api 0.41, Postgres 0.35 |

The upload burst at hangup is the heaviest moment (dev object storage peaked at 3.7 cores); with
Cloudflare R2 that work is remote.

### SIPp, 50 concurrent inbound calls
| Metric | Result |
|---|---|
| Calls | 100 successful, 0 failed |
| Concurrency / rate | 50 concurrent, 10 calls/s |
| INVITE → 200 OK | 30 ms average (includes the dialplan lookup and the agent leg answering) |
| Call length | 45 s |

## Problems found and fixed during the test
| Finding | Fix (in the product) |
|---|---|
| Carrier rejected the call (488, `INCOMPATIBLE_DESTINATION`) when the agent answered with Opus only — FreeSWITCH offered only the agent's codec | Carrier legs offer `PCMU,PCMA` explicitly (`absolute_codec_string`); FreeSWITCH transcodes |
| `DESTINATION_OUT_OF_ORDER` on bursts: FreeSWITCH's sessions-per-second throttle (30); each click-to-call creates 2 sessions | Switch limit 60/s (`SESSIONS_PER_SECOND`), controller paces originates at 20/s (`ORIGINATE_PER_SECOND`) |
| Only 25 calls fit: dev RTP range of 100 ports (2 per channel) | Dev range widened; production uses host networking with the full range (16384–32768 ≈ 8000 channels) |
| Recordings were 11 MB/minute (48 kHz stereo from the Opus leg) | `record_sample_rate=16000` → 3.75 MB/minute (verified) |
| Dev object storage ran out of volume slots | Dev SeaweedFS sizes its volume count from free disk |

## Sizing guidance
- 50 concurrent calls with transcoding used under half a core for FreeSWITCH. A 4 vCPU / 8 GB VM
  (PLAN.md §8) leaves ample headroom for 50–100 concurrent calls including recording; CPU grows
  roughly linearly with transcoded calls.
- Not covered by this test: SRTP/DTLS encryption of browser legs (the simulated agents use plain
  RTP; DTLS-SRTP adds a few percent CPU per leg) and TURN relaying (runs in coturn, not FreeSWITCH).
- Watch `voice_freeswitch_sessions`, `voice_api_xml_curl_duration_seconds` (p95 dialplan latency)
  and host CPU in Grafana; alert rules are in `infra/prometheus/alerts.yml`.

Reproduce: `CALLS=50 HOLD_SECONDS=60 ./scripts/loadtest.sh` (add `RECORD=1` for recording).
