Load Testing LLM Applications: A Practical Guide for Engineers
LLM load testing is the practice of simulating concurrent user traffic against an LLM-backed application to measure latency, throughput, error rates, and cost under realistic load. Unlike traditional web load testing, LLM load testing has to account for token-based rate limits, highly variable response times, and provider-side queuing that a simple HTTP load test will not surface. If you are shipping a chatbot, an agent, or a RAG pipeline to production, load testing is what tells you whether it survives launch day instead of falling over during a Twitter spike.
This guide walks through why LLM apps fail under load in ways normal APIs do not, how to design a load test that reflects real usage, which tools to use, and how to read the results so you fix the right bottleneck.
Why LLM applications fail differently under load
A normal REST API has fairly predictable latency: a database query takes roughly the same time whether it runs once or a thousand times in parallel, up to the point where the database itself saturates. LLM applications do not behave this way, for a few reasons.
Token-based rate limits, not request-based. Most LLM providers cap you on tokens per minute (TPM) and requests per minute (RPM) simultaneously. A handful of requests with long prompts or long completions can exhaust your TPM budget long before you hit the RPM ceiling. A load test that only counts requests per second will miss this entirely.
Latency scales with output length, not input. Generation is autoregressive: the model produces one token at a time. A response that is twice as long takes roughly twice as long to stream, independent of how complex the underlying reasoning was. This means your p95 latency is heavily influenced by the tail of your prompt distribution, specifically the prompts that trigger long answers, long chain-of-thought, or large tool outputs.
Provider-side queuing is invisible to you. When you hit a rate limit or the provider's own capacity limit, you get a 429 or a slow-drip response, not a clean failure. Under load, many teams see latency degrade gracefully into unusability before they see outright errors, which makes load tests that only track error rate misleading.
Downstream cost compounds with concurrency. Every concurrent conversation is also a concurrent token bill. A load test on a normal API tells you if the app survives. A load test on an LLM app also tells you what surviving costs, which matters for setting usage limits and pricing tiers.
Streaming changes what "response time" means. If you stream tokens to the client, "time to first token" (TTFT) and "time to last token" are two different metrics with two different failure modes. A system can have a fast TTFT and a terrible total completion time, and users will judge it very differently depending on which one degrades.
Defining what "load" means for your app
Before writing a single load test script, decide what real usage looks like. This is the step most teams skip, and it is why their load test numbers do not predict production behavior.
Answer these questions first:
- What is your expected concurrent user count at peak, not average? Peak is what breaks you.
- What is the realistic prompt length distribution? Pull 100-200 real prompts from logs or support tickets rather than guessing.
- Do requests fan out to multiple LLM calls (agents, RAG re-ranking, multi-step tool use)? If one user action triggers five model calls, your effective load is five times the user count.
- Is the traffic bursty (everyone hits "generate" after a scheduled event) or steady (background job processing)?
- What is your fallback behavior when a call times out or rate-limits? That behavior is itself something to load test.
Once you have this, write it down as a load profile, for example: "200 concurrent users, average prompt 400 tokens, p95 prompt 2,000 tokens, 15% of requests trigger a 3-step agent loop, traffic arrives in bursts of 50 users within 10 seconds during business hours."
Building the load test
Step 1: capture a realistic prompt corpus
Pull real prompts, not synthetic ones. Synthetic prompts tend to cluster around a narrow length and topic, which under-samples the long tail that actually causes latency spikes.
# pull last 500 user prompts from your logs table
SELECT prompt, prompt_tokens, completion_tokens, created_at
FROM llm_requests
ORDER BY created_at DESC
LIMIT 500;If you do not have production logs yet, write 50-100 prompts by hand covering: short factual questions, long multi-paragraph requests, prompts with code blocks or large pasted context, and prompts that trigger tool calls or retrieval.
Step 2: pick a load generation approach
You have three practical options.
Custom async script. Fastest to write, easiest to customize for token-aware pacing. Good default choice for most teams.
import asyncio
import time
import httpx
import random
PROMPTS = [...] # your captured corpus
CONCURRENCY = 50
ENDPOINT = "https://your-app.example.com/api/chat"
async def one_request(client, prompt, results):
start = time.perf_counter()
ttft = None
try:
async with client.stream("POST", ENDPOINT, json={"message": prompt}, timeout=60) as resp:
async for chunk in resp.aiter_bytes():
if ttft is None:
ttft = time.perf_counter() - start
total = time.perf_counter() - start
results.append({
"status": resp.status_code,
"ttft": ttft,
"total": total,
})
except Exception as e:
results.append({"status": "error", "error": str(e), "total": time.perf_counter() - start})
async def run_load_test():
results = []
limits = httpx.Limits(max_connections=CONCURRENCY)
async with httpx.AsyncClient(limits=limits) as client:
sem = asyncio.Semaphore(CONCURRENCY)
async def bound_request(prompt):
async with sem:
await one_request(client, prompt, results)
tasks = [bound_request(random.choice(PROMPTS)) for _ in range(500)]
await asyncio.gather(*tasks)
return results
if __name__ == "__main__":
results = asyncio.run(run_load_test())
errors = [r for r in results if r["status"] != 200]
ttfts = sorted(r["ttft"] for r in results if r.get("ttft"))
totals = sorted(r["total"] for r in results)
print(f"requests: {len(results)}, errors: {len(errors)}")
if ttfts:
print(f"ttft p50: {ttfts[len(ttfts)//2]:.2f}s, p95: {ttfts[int(len(ttfts)*0.95)]:.2f}s")
print(f"total p50: {totals[len(totals)//2]:.2f}s, p95: {totals[int(len(totals)*0.95)]:.2f}s")This gives you both TTFT and total completion time, which matters for anything streaming.
k6 or Locust. Use these if your team already has load testing infrastructure and dashboards built around them. k6 in particular handles ramping load profiles (gradual ramp-up, sustained plateau, spike) more cleanly than a hand-rolled script.
// k6 script, load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '1m', target: 20 }, // ramp up
{ duration: '3m', target: 20 }, // sustained
{ duration: '30s', target: 100 }, // spike
{ duration: '1m', target: 100 }, // sustained spike
{ duration: '1m', target: 0 }, // ramp down
],
};
const prompts = JSON.parse(open('./prompts.json'));
export default function () {
const prompt = prompts[Math.floor(Math.random() * prompts.length)];
const res = http.post(
'https://your-app.example.com/api/chat',
JSON.stringify({ message: prompt }),
{ headers: { 'Content-Type': 'application/json' }, timeout: '60s' }
);
check(res, {
'status is 200': (r) => r.status === 200,
'has content': (r) => r.body.length > 0,
});
sleep(Math.random() * 2);
}Run it with k6 run --out json=results.json load-test.js and pipe results into whatever dashboard you use.
Provider-side load testing. If you want to isolate model latency from your own application code, load test the LLM API directly (OpenAI, Anthropic, or whichever provider you use), bypassing your backend. This tells you the floor: the best latency you could get with zero application overhead. Compare this against your end-to-end numbers to see how much latency your own stack is adding.
Step 3: instrument what you are measuring
Track these metrics per request, not just in aggregate:
- Time to first token (TTFT), if streaming
- Total completion time
- Input and output token counts
- HTTP/API status code, separating rate-limit errors (429) from server errors (5xx) from timeouts
- Retry count, if your app retries on failure
- Cost per request, computed from token counts and provider pricing
Log these to a structured format so you can compute percentiles afterward, not just averages. Averages hide the tail, and the tail is what users complain about.
# minimal structured logging for later analysis
import json
def log_result(prompt_id, status, ttft, total, input_tokens, output_tokens):
print(json.dumps({
"prompt_id": prompt_id,
"status": status,
"ttft_ms": round(ttft * 1000) if ttft else None,
"total_ms": round(total * 1000),
"input_tokens": input_tokens,
"output_tokens": output_tokens,
}))Reading the results
Watch p95 and p99, not the average. A mean latency of 2 seconds can hide a p99 of 30 seconds if a small fraction of prompts are triggering long generations or retries. Users on the p99 tail are the ones who churn.
Separate rate-limit errors from real failures. A spike in 429s under load is not a bug, it is your rate limit doing its job. What matters is whether your application handles it gracefully (queuing, backoff, a friendly "high demand" message) rather than surfacing a raw error to the user.
Check if latency degrades linearly or falls off a cliff. Plot latency against concurrency. If latency rises smoothly as you add load, you have a capacity problem you can plan around (add concurrency limits, queue requests, scale workers). If latency is flat and then suddenly spikes at some threshold, you have hit a hard resource limit, likely a connection pool, a rate limit boundary, or a downstream service (vector database, cache) saturating.
Correlate errors with token volume, not just request count. If you have TPM limits, plot errors against tokens-per-minute consumed rather than requests-per-minute. This is usually where teams find their real bottleneck: they were nowhere near their RPM limit but well past their TPM limit because a handful of long prompts dominated the token budget.
Test your fallback path under load, deliberately. If your app falls back to a smaller or cheaper model when the primary is rate-limited, run a load test that intentionally exhausts the primary and confirm the fallback actually engages and stays within its own limits. Fallback paths are often the least-tested part of the system precisely because they only trigger under load.
Common bottlenecks and what to do about them
Connection pool exhaustion. If your backend opens a new HTTP connection per request to the LLM provider, high concurrency can exhaust available sockets. Use a persistent connection pool with a sized limit that matches your expected concurrency.
Synchronous request handling. If your backend blocks a worker thread for the full duration of a streaming LLM call, you will run out of workers long before you run out of CPU. Move to an async framework or a worker model that can hold many in-flight requests without pinning a thread each.
No request queuing or backpressure. Without a queue, a burst of traffic sends every request straight to the provider simultaneously, guaranteeing a wave of 429s. Adding a simple queue with a concurrency cap in front of the provider call smooths this out and usually improves overall throughput, not just fairness.
Retry storms. A naive retry-on-429 strategy without backoff makes rate limiting worse, not better, because failed requests immediately resubmit and compound the load. Use exponential backoff with jitter, and cap total retries.
import random
import asyncio
async def call_with_backoff(fn, max_retries=4):
for attempt in range(max_retries):
try:
return await fn()
except RateLimitError:
if attempt == max_retries - 1:
raise
delay = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(delay)Unbounded context growth in agents. Multi-step agent loops that accumulate conversation history without trimming will see per-request token counts grow across a session, which quietly increases both latency and cost as a session ages. Load test long-running sessions specifically, not just single-turn requests.
Cold starts on serverless deployments. If your backend runs on serverless infrastructure, a load spike after idle time triggers cold starts that add real latency on top of model latency. Load test from a cold state, not just a warmed-up one, to catch this.
Setting a load testing cadence
Run a load test before every launch that could cause a traffic spike (product hunt post, newsletter send, feature announcement). Beyond launches, run a lightweight version on a schedule, weekly or per release, so you catch regressions before users do. Track your key percentiles over time the same way you would track error rate or uptime, and set alerting thresholds on p95 latency and rate-limit error rate, not just on total downtime.
FAQ
How is LLM load testing different from normal API load testing? LLM load testing has to account for token-based rate limits in addition to request-based limits, highly variable latency driven by output length rather than input complexity, and streaming metrics like time to first token that a normal HTTP load test does not capture. Cost per request is also a first-class metric, since concurrency directly drives spend.
How many concurrent users should I simulate? Start with your realistic peak, not your average, and add a margin for unexpected spikes such as a viral post or a scheduled email blast. If you do not have production data yet, simulate at least 3-5x your expected launch-day average as a stress scenario, then scale down to find the point where latency starts degrading.
What is a good p95 latency target for an LLM app? There is no universal number since it depends heavily on response length and whether you stream. What matters more is consistency: define a target based on your own baseline (for example, p95 total completion time no more than 2x your p50), and alert when that ratio breaks rather than chasing an absolute number.
Should I load test against the real LLM provider or a mock? Do both. Load test against a mock first to validate your own application code, connection pooling, and queuing logic without burning tokens or tripping provider rate limits. Then run a smaller, real load test against the actual provider to validate true end-to-end latency and confirm your rate limit assumptions.
How do I load test streaming responses specifically? Measure time to first token and total completion time separately, and record them per request rather than averaging across a whole run. A streaming UI can feel responsive even with a slow total completion time if TTFT is fast, so both numbers need their own targets.
Can load testing catch prompt-related cost problems? Yes, if you log input and output token counts per request during the test. Multiply those against your provider's pricing to get a cost-per-request distribution, which often reveals that a small number of prompts (long context, verbose completions) are driving a disproportionate share of total spend under load.
What tools are best for a small team just getting started? A hand-rolled async script in Python or Node with a semaphore for concurrency control is enough for most early-stage load testing and gives you full control over token-aware metrics. Move to k6 or Locust once you need repeatable ramp profiles, historical dashboards, or want load testing integrated into CI.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.