npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vicistack/call-center-staffing-formula

v1.0.0

Published

Why Erlang C Always Understaffs Your Call Center (And What to Use Instead) — ViciStack call center engineering guide

Readme

Why Erlang C Always Understaffs Your Call Center (And What to Use Instead)

Erlang C gives you a number. Reality gives you a different one. This guide covers both: the math behind call center staffing, why Erlang C is wrong in predictable ways, and how to build a staffing model that accounts for shrinkage, blended campaigns, multi-skill routing, and agents who call in sick on Mondays. --- ## The Problem Nobody Admits Every call center manager has been in this meeting. Someone from finance asks "how many agents do we actually need?" and somebody pulls up an Erlang C calculator from a website that looks like it was built in 2003, plugs in three numbers, and says "thirty-two agents." Everyone nods. Nobody asks why Monday at 9 AM has a 12-minute wait time while Thursday at 2 PM has six agents sitting idle. The Erlang C formula is a beautiful piece of queuing theory. A.K. Erlang published it in 1917 to figure out how many telephone circuits the Copenhagen Telephone Company needed. It works. It just doesn't work the way most people use it. Here's what Erlang C assumes: - Calls arrive randomly following a Poisson distribution - All agents are identical and interchangeable - No calls abandon (callers wait forever) - Agents handle one call, then immediately take the next - Call arrival rate is constant during the measurement period - There's no wrap-up time between calls - Nobody takes breaks, calls in sick, or goes to lunch Exactly zero of these are true in a real call center. But Erlang C is still where you start, because it gives you the mathematical floor -- the minimum number of agents you'd need in a universe where everything works perfectly. Then you adjust for the real world. --- ## Step 1: Get Your Actual Numbers Before touching any formula, you need three numbers from your actual operation. Not your guesses. Not what the campaign manager told you last quarter. Actual numbers from your database. ### Call Volume Per Half-Hour If you're running VICIdial, pull inbound call volume from the vicidial_closer_log: sql SELECT DATE(call_date) AS day, CONCAT(LPAD(HOUR(call_date), 2, '0'), ':', IF(MINUTE(call_date) < 30, '00', '30')) AS half_hour, COUNT(*) AS calls FROM vicidial_closer_log WHERE call_date >= DATE_SUB(NOW(), INTERVAL 4 WEEK) AND campaign_id = 'INBOUND_SALES' AND status NOT IN ('DROP','XDROP') GROUP BY day, half_hour ORDER BY day, half_hour; For outbound, use vicidial_log: sql SELECT DATE(call_date) AS day, CONCAT(LPAD(HOUR(call_date), 2, '0'), ':', IF(MINUTE(call_date) < 30, '00', '30')) AS half_hour, COUNT(*) AS calls, AVG(length_in_sec) AS avg_talk_sec FROM vicidial_log WHERE call_date >= DATE_SUB(NOW(), INTERVAL 4 WEEK) AND campaign_id = 'OUTBOUND_B2B' AND length_in_sec > 0 GROUP BY day, half_hour ORDER BY day, half_hour; Take the average for each half-hour across at least four weeks. Two weeks of data is noisy. Eight weeks is better if your volume is seasonal. ### Average Handle Time (AHT) AHT is talk time + hold time + after-call work (wrap-up/disposition time). Most people only measure talk time and wonder why they're always short-staffed. sql SELECT AVG(length_in_sec) AS avg_talk_sec, AVG(TIMESTAMPDIFF(SECOND, end_epoch_time, FROM_UNIXTIME( (SELECT MIN(UNIX_TIMESTAMP(event_time)) FROM vicidial_agent_log val2 WHERE val2.user = val.user AND val2.event_time > FROM_UNIXTIME(val.end_epoch_time) AND val2.sub_status IN ('READY','INCALL')) ))) AS avg_wrapup_sec FROM vicidial_agent_log val WHERE event_time >= DATE_SUB(NOW(), INTERVAL 4 WEEK) AND campaign_id = 'INBOUND_SALES' AND talk_sec > 0; That wrap-up query is ugly. In practice, most VICIdial shops measure AHT by looking at the time between call end and the agent going back to READY status. A simpler approximation: sql SELECT user, AVG(talk_sec + dispo_sec) AS avg_handle_time, STDDEV(talk_sec + dispo_sec) AS handle_time_stddev FROM vicidial_agent_log WHERE event_time >= DATE_SUB(NOW(), INTERVAL 4 WEEK) AND talk_sec > 0 AND campaign_id = 'INBOUND_SALES' GROUP BY user ORDER BY avg_handle_time DESC; The standard deviation matters here. If your average AHT is 240 seconds but the stddev is 180, you don't have one AHT -- you have a bimodal distribution. Some calls are 60 seconds (quick answers) and some are 500 seconds (complex issues). The Erlang formula can't handle bimodal distributions, and this is one of its major blind spots. For a typical inbound support campaign, expect an AHT somewhere between 180 and 420 seconds. Sales calls tend to run 240-600 seconds. Collections are usually 120-300 seconds. ### Service Level Target This is the percentage of calls you want answered within a specified number of seconds. The industry standard is "80/20" -- 80% of calls answered within 20 seconds. But there's nothing sacred about 80/20. You pick the numbers based on your business: - 80/20: Standard for most call centers. Reasonable balance. - 90/10: Premium support, high-value customers. Expensive. - 80/30: Common for non-emergency inbound. Saves money, customers don't complain much. - 70/60: Budget operations. Callers start abandoning around 45 seconds. The difference between 80/20 and 90/10 can be 30-40% more agents for the same volume. That's not a typo. The relationship between service level and staffing is exponential, not linear. Going from 80% answered in 20 seconds to 90% answered in 10 seconds doesn't cost 12.5% more. It costs a lot more. --- ## Step 2: The Erlang C Formula The Erlang C formula calculates the probability that a call has to wait in queue. Here it is in its full mathematical glory: P(wait > 0) = (A^N / N!) * (N / (N - A)) / [sum(k=0 to N-1) (A^k / k!) + (A^N / N!) * (N / (N - A))] Where: - A = traffic intensity in Erlangs = (calls per interval * AHT) / interval length - N = number of agents - P(wait > 0) = probability a call waits And the service level formula: SL = 1 - P(wait > 0) * e^(-(N - A) * (target_wait / AHT)) Where: - SL = service level (fraction of calls answered within target wait time) - target_wait = your acceptable wait time in seconds (e.g., 20) Nobody computes this by hand. Here's a Python implementation you can actually run: python #!/usr/bin/env python3 """ erlang_c.py — Staffing calculator using Erlang C """ import math from functools import lru_cache @lru_cache(maxsize=1024) def erlang_c(agents: int, traffic: float) -> float: """Probability a call waits (Erlang C).""" if agents <= traffic: return 1.0 # system is at or over capacity # Use log-space to avoid factorial overflow log_numerator = traffic * math.log(traffic) - math.lgamma(agents + 1) log_denominator_sum = 0 terms = [] for k in range(agents): terms.append(math.exp(k * math.log(traffic) - math.lgamma(k + 1))) last_term = math.exp(log_numerator) rho = traffic / agents last_term_adjusted = last_term / (1 - rho) total = sum(terms) + last_term_adjusted return last_term_adjusted / total def service_level(agents: int, calls_per_interval: float, aht_sec: float, interval_sec: float, target_wait_sec: float) -> float: """Fraction of calls answered within target_wait_sec.""" traffic = (calls_per_interval * aht_sec) / interval_sec if agents <= traffic: return 0.0 pw = erlang_c(agents, traffic) sl = 1 - pw * math.exp(-(agents - traffic) * (target_wait_sec / aht_sec)) return max(0.0, min(1.0, sl)) def find_agents(calls_per_interval: float, aht_sec: float, interval_sec: float = 1800, target_sl: float = 0.80, target_wait_sec: float = 20) -> dict: """Find minimum agents to meet service level target.""" traffic = (calls_per_interval * aht_sec) / interval_sec min_agents = max(1, int(math.ceil(traffic))) for n in range(min_agents, min_agents + 200): sl = service_level(n, calls_per_interval, aht_sec, interval_sec, target_wait_sec) if sl >= target_sl: return { 'agents': n, 'traffic_erlangs': round(traffic, 2), 'service_level': round(sl * 100, 1), 'occupancy': round(traffic / n * 100, 1), 'calls_per_interval': calls_per_interval, 'aht_sec': aht_sec, } return {'agents': -1, 'error': 'Could not find solution in range'} # Example: 120 calls per half-hour, 240 sec AHT, 80/20 target if __name__ == '__main__': result = find_agents( calls_per_interval=120, aht_sec=240, interval_sec=1800, target_sl=0.80, target_wait_sec=20 ) print(f"Agents needed: {result['agents']}") print(f"Traffic intensity: {result['traffic_erlangs']} Erlangs") print(f"Service level: {result['service_level']}%") print(f"Occupancy: {result['occupancy']}%") Running this with 120 calls per half-hour and a 240-second AHT gives you: Agents needed: 19 Traffic intensity: 16.0 Erlangs Service level: 82.1% Occupancy: 84.2% Nineteen agents. Write that down, because it's wrong. It's the correct answer to the wrong question. --- ## Step 3: Why Erlang C Is Wrong (And How Wrong) Here's a table showing the Erlang C result versus what you'll actually need for a range of scenarios: | Scenario | Calls/30min | AHT (sec) | Erlang C Says | You Actually Need | Gap | |----------|------------|-----------|---------------|-------------------|-----| | Small inbound support | 30 | 300 | 7 | 9-10 | +29-43% | | Medium sales inbound | 80 | 240 | 14 | 18-20 | +29-43% | | Large blended center | 200 | 210 | 26 | 33-37 | +27-42% | | Collections floor | 50 | 180 | 7 | 9-11 | +29-57% | | High-touch B2B sales | 25 | 600 | 11 | 14-16 | +27-45% | The gap is consistently 27-57%. That's not noise. It's the accumulated weight of everything Erlang C ignores. Let's quantify each factor. ### Factor 1: Shrinkage (Usually 25-35%) Shrinkage is the percentage of paid time agents are unavailable to handle calls. It includes: - Breaks (15 min morning + 15 min afternoon = 30 min/day) - Lunch (30-60 min/day) - Training and meetings (2-4 hours/week) - Coaching sessions (30-60 min/week per agent) - System issues (computer freezing, VPN dropping, softphone glitching) - Unscheduled absences (sick days, personal emergencies, no-shows) - Late arrivals and early departures Here's how to calculate your actual shrinkage from VICIdial data: sql SELECT DATE(event_time) AS day, user, SUM(CASE WHEN status = 'PAUSED' THEN pause_sec ELSE 0 END) AS pause_seconds, SUM(CASE WHEN status = 'READY' OR status = 'INCALL' THEN wait_sec + talk_sec ELSE 0 END) AS productive_seconds, SUM(pause_sec + wait_sec + talk_sec + dispo_sec) AS total_logged_seconds, ROUND( SUM(CASE WHEN status = 'PAUSED' THEN pause_sec ELSE 0 END) / SUM(pause_sec + wait_sec + talk_sec + dispo_sec) * 100, 1 ) AS shrinkage_pct FROM vicidial_agent_log WHERE event_time >= DATE_SUB(NOW(), INTERVAL 4 WEEK) GROUP BY day, user ORDER BY shrinkage_pct DESC; Most operations discover their shrinkage is higher than they thought. The industry average is 30-35% for on-site centers and 35-40% for remote agents (home distractions, internet issues, longer breaks). To adjust for shrinkage: Adjusted agents = Erlang C agents / (1 - shrinkage_rate) So if Erlang C says 19 and your shrinkage is 32%: Adjusted = 19 / (1 - 0.32) = 19 / 0.68 = 28 agents That's 28 scheduled agents to have 19 actually taking calls at any given moment. Already a 47% increase from the Erlang C number. ### Factor 2: Occupancy Limits (Don't Burn Your Agents) Occupancy is the percentage of time an agent spends on calls versus waiting. At 84% occupancy (from our example above), agents spend 50 minutes of every hour on the phone. That sounds efficient. It's also a fast track to burnout and turnover. Sustainable occupancy depends on the type of work: | Call Type | Max Sustainable Occupancy | Why | |-----------|--------------------------|-----| | Inbound support (simple) | 85-88% | Repetitive but low stress | | Inbound sales | 78-82% | High cognitive load, rejection | | Outbound cold calling | 75-80% | Highest stress, most rejection | | Collections | 72-78% | Emotionally draining | | Blended in/outbound | 80-85% | Variety reduces fatigue | If your staffing model produces 90%+ occupancy, you're going to have a turnover problem within 60 days. Agents will start calling in sick, taking longer breaks, and quitting. The cost of replacing one agent (recruiting, training, ramp-up time) is typically $3,000-8,000. Burning your agents to save on staffing is one of the most expensive mistakes a call center can make. ### Factor 3: Call Abandonment (Erlang C Ignores It) Erlang C assumes callers wait forever. They don't. In practice: - 25% of callers hang up after 30 seconds - 50% hang up after 60 seconds - 70% hang up after 120 seconds This creates a paradox: if you're understaffed, some callers abandon, which reduces the apparent call volume, which makes Erlang C tell you that you need fewer agents. The formula rewards you for bad service. The fix is to add abandoned calls back into your volume calculation: ```sql SELECT CONCAT(LPAD(HOUR(call_date), 2, '0'), ':', IF(MINUTE(call_date) < 30, '00', '30'))


Read the full article

About

Built by ViciStack — enterprise VoIP and call center infrastructure.

License

MIT