Scaling Realtime Agent Sessions: Connections, Queues, and State
Architecture patterns for scaling AI voice agents to 1000+ concurrent sessions using horizontal scaling, connection pooling, and queue management.
Published on • September 13, 2026
AI Assistant

Scaling Realtime Agent Sessions: Connections, Queues, and State
A voice agent handling ten calls is a prototype. One handling a thousand simultaneous calls is a distributed system with sticky sessions, connection limits, queue back-pressure, and graceful drain. The transition from ten to a thousand is where most teams ship an outage.
The Scaling Challenge
Why Voice Agents Are Different
# Traditional API: Stateless, short-lived
request → process → response → done
# Voice Agent: Stateful, long-lived
session_start → streaming_audio → conversation → tool_calls → session_end
(minutes to hours)
Voice agents have unique scaling requirements:
- Long-lived connections: Sessions last minutes to hours
- Stateful interactions: Each turn depends on conversation history
- Real-time streaming: Sub-second latency requirements
- Resource-heavy: LLM calls block for seconds
Capacity Planning
class CapacityPlanner:
def __init__(self):
self.pod_capacity = {
'fastapi': 20-40, # Concurrent Realtime sessions
'websocket': 100-300, # WebSocket connections
'http': 1000, # Short-lived HTTP requests
}
def calculate_pods_needed(self, expected_concurrent_calls: int) -> int:
"""Calculate pods needed for expected load."""
calls_per_pod = self.pod_capacity['fastapi']
pods_needed = expected_concurrent_calls / calls_per_pod
# Add 20% headroom
return int(pods_needed * 1.2)
Architecture Patterns
1. Stateless Design with External State
class StatelessVoiceAgent:
def __init__(self, redis_client, db_client):
self.redis = redis_client
self.db = db_client
async def handle_session(self, session_id: str, audio_chunk: bytes):
"""Handle audio without maintaining local state."""
# Read state from Redis
state = await self.redis.get(f"session:{session_id}")
# Process audio
response = await self.process_audio(audio_chunk, state)
# Write state back to Redis
await self.redis.set(
f"session:{session_id}",
response['new_state'],
ex=3600 # 1 hour TTL
)
return response['audio']
2. Sticky Sessions for WebSocket
# Kubernetes Ingress with session affinity
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: voice-agent-ingress
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie"
nginx.ingress.kubernetes.io/session-cookie-name: "VOICE_AGENT_SESSION"
nginx.ingress.kubernetes.io/session-cookie-max-age: "3600"
spec:
rules:
- host: voice.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: voice-agent-service
port:
number: 8080
3. Connection Pooling
class ConnectionPool:
def __init__(self, max_connections: int = 20):
self.max_connections = max_connections
self.semaphore = asyncio.Semaphore(max_connections)
self.connections = []
async def get_connection(self):
"""Get connection from pool."""
async with self.semaphore:
if self.connections:
return self.connections.pop()
return await self.create_connection()
async def release_connection(self, conn):
"""Return connection to pool."""
if len(self.connections) < self.max_connections:
self.connections.append(conn)
else:
await conn.close()
Queue Management
Message Queue for Agent Communication
import redis.asyncio as redis
class AgentMessageQueue:
def __init__(self):
self.redis = redis.Redis()
self.stream_name = "agent_messages"
async def enqueue_message(self, agent_id: str, message: dict):
"""Add message to agent's queue."""
await self.redis.xadd(
self.stream_name,
{
"agent_id": agent_id,
"message": json.dumps(message),
"timestamp": time.time()
}
)
async def dequeue_messages(self, agent_id: str, count: int = 10):
"""Get messages for specific agent."""
messages = await self.redis.xread(
{self.stream_name: "$"},
count=count,
block=100 # Wait 100ms for messages
)
return [
json.loads(msg[b"message"])
for msg in messages.get(self.stream_name, [])
]
Priority Queue for Task Scheduling
class PriorityTaskQueue:
def __init__(self):
self.redis = redis.Redis()
self.queue_key = "task_queue"
async def add_task(self, task: dict, priority: int = 0):
"""Add task with priority (higher = more urgent)."""
score = priority * 1000000 + time.time()
await self.redis.zadd(
self.queue_key,
{json.dumps(task): score}
)
async def get_next_task(self) -> Optional[dict]:
"""Get highest priority task."""
result = await self.redis.zpopmin(self.queue_key)
if result:
task_json, _ = result[0]
return json.loads(task_json)
return None
Auto-Scaling Configuration
Kubernetes Horizontal Pod Autoscaler
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: voice-agent-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: voice-agent
minReplicas: 10
maxReplicas: 200
metrics:
- type: Pods
pods:
metric:
name: active_calls
target:
type: AverageValue
averageValue: "25"
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 5
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 2
periodSeconds: 120
Custom Metrics for Scaling
class VoiceAgentMetrics:
def __init__(self):
self.metrics = {
'active_calls': 0,
'queue_depth': 0,
'p95_latency_ms': 0
}
def record_call_start(self):
self.metrics['active_calls'] += 1
def record_call_end(self):
self.metrics['active_calls'] -= 1
def get_scaling_metric(self) -> float:
"""Return metric for HPA."""
return self.metrics['active_calls']
Graceful Drain and Deployment
Rolling Updates
apiVersion: apps/v1
kind: Deployment
metadata:
name: voice-agent
spec:
replicas: 20
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
terminationGracePeriodSeconds: 660
containers:
- name: voice-agent
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
Drain Endpoint
class DrainableAgent:
def __init__(self):
self.is_draining = False
self.active_sessions = set()
async def drain(self):
"""Start draining process."""
self.is_draining = True
# Stop accepting new sessions
# Wait for active sessions to complete
while self.active_sessions:
await asyncio.sleep(1)
# Ready for shutdown
return True
async def handle_session(self, session_id: str):
"""Handle session with drain awareness."""
if self.is_draining:
return {"error": "Service is draining"}
self.active_sessions.add(session_id)
try:
# Process session
pass
finally:
self.active_sessions.discard(session_id)
State Management Patterns
Redis for Hot State
class RedisStateManager:
def __init__(self):
self.redis = redis.Redis()
async def get_session_state(self, session_id: str) -> dict:
"""Get session state from Redis."""
state_json = await self.redis.get(f"session:{session_id}")
return json.loads(state_json) if state_json else {}
async def update_session_state(self, session_id: str,
updates: dict):
"""Update session state atomically."""
state = await self.get_session_state(session_id)
state.update(updates)
await self.redis.set(
f"session:{session_id}",
json.dumps(state),
ex=3600
)
PostgreSQL for Durable State
class PostgresStateManager:
def __init__(self):
self.engine = create_async_engine(
"postgresql+asyncpg://...",
pool_size=20,
max_overflow=10
)
async def save_conversation(self, session_id: str,
messages: list):
"""Save conversation history to Postgres."""
async with self.engine.begin() as conn:
await conn.execute(
insert(conversations).values(
session_id=session_id,
messages=json.dumps(messages),
updated_at=datetime.utcnow()
)
)
Monitoring and Alerting
Key Metrics to Monitor
class ProductionMetrics:
def __init__(self):
self.metrics = {
'active_sessions': 0,
'queue_depth': 0,
'p95_latency_ms': 0,
'error_rate': 0,
'memory_usage_mb': 0
}
def check_alerts(self) -> list:
"""Check for alert conditions."""
alerts = []
if self.metrics['queue_depth'] > 100:
alerts.append('HIGH_QUEUE_DEPTH')
if self.metrics['p95_latency_ms'] > 500:
alerts.append('HIGH_LATENCY')
if self.metrics['error_rate'] > 0.05:
alerts.append('HIGH_ERROR_RATE')
return alerts
Best Practices
1. Right-Size Your Pods
# Per-pod capacity planning
pod_limits = {
'cpu': '2000m', # 2 CPU cores
'memory': '4Gi', # 4GB RAM
'concurrent_sessions': 30, # Target
'memory_per_session': '100MB' # Budget
}
# Calculate pod count
pods_needed = total_concurrent_sessions / pod_limits['concurrent_sessions']
2. Use Connection Pooling
# Don't create new connections per session
class BadPattern:
async def handle_session(self):
db = await create_connection() # Bad: new connection each time
await db.query(...)
class GoodPattern:
def __init__(self):
self.pool = ConnectionPool(max_size=20)
async def handle_session(self):
async with self.pool.get_connection() as db: # Good: reuse
await db.query(...)
3. Implement Circuit Breakers
class CircuitBreaker:
def __init__(self, failure_threshold=5, recovery_timeout=30):
self.failure_count = 0
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.last_failure_time = 0
def record_failure(self):
self.failure_count += 1
self.last_failure_time = time.time()
def record_success(self):
self.failure_count = 0
def should_allow_request(self) -> bool:
if self.failure_count < self.failure_threshold:
return True
if time.time() - self.last_failure_time > self.recovery_timeout:
return True
return False
Conclusion
Scaling voice agents to 1000+ concurrent sessions requires:
- Externalize state: Use Redis for hot state, Postgres for durability
- Sticky sessions: For WebSocket streaming, not entire lifecycle
- Custom metrics: Scale on active calls, not CPU
- Graceful drain: Don’t kill live calls on deployment
- Connection pooling: Never create new connections per session
The transition from prototype to production is where most teams fail. By implementing these patterns—stateless design, proper queue management, and careful scaling—you can handle thousands of concurrent sessions while maintaining sub-second latency.