Skip to main content
Production LLM applications require handling multiple concurrent requests efficiently. This chapter covers async patterns, rate limiting, and strategies for scaling LLM operations — the same infrastructure that separates toy demos from systems handling real traffic. The core insight is this: LLM API calls are I/O-bound, not CPU-bound. While you’re waiting 2-15 seconds for a model to generate a response, your CPU is doing nothing. Async programming lets you fire off dozens of those calls simultaneously, turning sequential 2-minute workflows into 15-second parallel bursts. If you’re processing 1,000 prompts sequentially at 3 seconds each, that’s 50 minutes. With async concurrency of 20, it’s about 2.5 minutes — same cost, same results, 20x faster. Think of it like ordering food for a large group: you don’t wait for person #1’s meal to arrive before ordering for person #2. You send all the orders to the kitchen at once and let them come back as they’re ready.

Async Fundamentals

Basic Async LLM Calls

Parallel Requests with gather

asyncio.gather() is the workhorse for firing multiple LLM calls simultaneously. It takes a list of coroutines, runs them all concurrently, and returns results in the same order as the input. This is the difference between “ask 4 questions one at a time” and “ask all 4 at once.” Critical production warning: gather() with no concurrency limit will fire ALL requests simultaneously. If you have 1,000 prompts, that’s 1,000 concurrent API calls — and you’ll hit rate limits instantly. Always pair gather() with a semaphore (covered below) for production workloads.

Rate Limiting

Without rate limiting, your async code will happily fire thousands of concurrent requests in milliseconds — and the API provider will just as happily reject most of them with 429 (Too Many Requests) errors. Rate limiting is not optional; it’s the difference between “10x faster batch processing” and “10x faster at generating error responses.”

Token Bucket Rate Limiter

The token bucket is the gold standard algorithm for API rate limiting. The analogy: imagine a bucket that holds tokens. Tokens drip in at a steady rate (say, 1 per second). Each API call costs one token. If the bucket is empty, you wait. If it’s full, you can burst up to the bucket’s capacity. This is better than a simple “N requests per second” limit because it allows natural bursting: a user who was idle for 10 seconds has accumulated tokens and can fire a quick burst — which matches how humans actually use APIs.

Sliding Window Rate Limiter

The sliding window approach tracks actual request timestamps rather than maintaining abstract token counts. It’s conceptually simpler and gives you exact enforcement: “no more than N requests in any rolling T-second window.” The tradeoff versus token bucket is that it doesn’t allow bursting — the 11th request in a 10-request window always waits, even if the previous 10 requests happened over a long period.
When to use which rate limiter: Token bucket is the default choice for most LLM workloads — it handles bursty traffic gracefully and composes well with dual RPM/TPM limits. Use sliding window only when you need strict guarantees (e.g., “never more than exactly 10 requests in any 1-second window”) or when auditing requires exact request-level timestamps.

Backoff Strategies

When an LLM API returns a rate limit error or a transient server error, the worst thing you can do is immediately retry. If 100 of your concurrent requests all get rate-limited at the same time and all retry after exactly 1 second, you’ve just created a “thundering herd” that hits the API with 100 simultaneous requests again — and gets rate-limited again. The cycle repeats. Exponential backoff with jitter solves this by spreading retries over time. Each successive retry waits longer (exponential), and each wait is randomized within a range (jitter). The result: your 100 retries spread themselves naturally across a 30-second window instead of slamming the API all at once.

Exponential Backoff with Jitter

Adaptive Rate Limiting

Static rate limits are a guess. Adaptive rate limiting observes how the API actually responds and adjusts in real-time. When requests succeed, it cautiously increases the rate. When it gets rate-limited, it aggressively backs off. Think of it like a driver adjusting speed based on traffic conditions rather than always driving at exactly 60mph. This pattern is especially valuable when you don’t know the exact rate limit (many providers don’t publish them), when limits change based on server load, or when you’re sharing a rate limit pool with other tenants in your organization.
Adaptive rate limiters have a cold-start problem. When your application restarts, the limiter has no memory of the true rate limit and starts conservative. If you know your API tier limits, set initial_rate close to the actual limit divided by your expected concurrency to minimize ramp-up time. Also, log current_rate to your metrics dashboard — the rate over time graph is one of the best debugging tools for throughput issues.

Semaphore-Based Concurrency Control

A semaphore is the simplest and most reliable way to cap concurrency. If rate limiting controls “how fast” you make requests, semaphores control “how many at once.” Even with perfect rate limiting, having 500 concurrent HTTP connections can exhaust memory, file descriptors, or connection pool limits. The rule of thumb for LLM APIs: start with max_concurrent=10 and increase until you start seeing rate limit errors or degraded latency. Most providers handle 10-50 concurrent connections per API key without issue.

Request Queue with Priority

Not all LLM requests are created equal. A real-time chat response should jump ahead of a background summarization job. A paying customer’s request should take priority over a free tier user’s batch job. Priority queues let you enforce these business rules at the infrastructure level. The pattern is simple: instead of a FIFO queue, use a min-heap where lower priority numbers go first. Within the same priority level, requests are processed in FIFO order (using a monotonic sequence number as a tiebreaker).
Priority queue anti-pattern: Do not use priorities to implement fairness across users. If user A submits 10,000 LOW-priority items and user B submits 1 HIGH-priority item, user B gets served first — but that is not multi-tenant fairness, it is priority scheduling. For multi-tenant fairness, use weighted fair queuing (round-robin across tenants with configurable weights) rather than absolute priority classes.

Async Context Manager for Sessions

For production batch processing, you want session-level resource management and statistics. The context manager pattern (async with) guarantees cleanup happens even when exceptions occur — no leaked connections, no orphaned tasks. This is the pattern to use when you need to answer questions like “how many tokens did this batch consume?” or “what was the p99 latency for this run?” — questions that matter when your monthly LLM bill has commas in it.
Async Best Practices for LLMs
  • Always use connection pooling with async clients
  • Implement proper timeout handling for all requests
  • Use semaphores to control maximum concurrent requests
  • Add jitter to retry delays to prevent thundering herd
  • Monitor and adapt rate limits based on API responses

Practice Exercise

Build an async batch processor with:
  1. Priority queue for request ordering
  2. Adaptive rate limiting based on API responses
  3. Exponential backoff with jitter for retries
  4. Progress tracking and cancellation support
  5. Comprehensive statistics collection
Focus on:
  • Proper resource cleanup on failures
  • Graceful shutdown handling
  • Memory-efficient batch processing
  • Real-time progress reporting

Interview Deep-Dive

What interviewers are testing: Whether you can design a real batch processing pipeline with concrete numbers, not just describe patterns abstractly.Strong answer: Start with the math. At 500 RPM, sequential processing takes 100 minutes minimum. With an average of 400 tokens per request (prompt + completion), the token limit allows 500 requests/minute too, so requests-per-minute is the binding constraint.Architecture: Use a producer-consumer pattern with an async queue. The producer reads prompts from a file or database in chunks (not all 50K into memory). Workers (20-50 concurrent coroutines gated by a semaphore) pull from the queue, make rate-limited API calls, and write results to an output queue.Rate limiting: Use a token bucket with capacity=500 and refill rate of ~8.3/second. Add a semaphore at 30-50 concurrent connections. The dual limiter ensures you respect both RPM and connection limits.Resilience: Implement checkpointing every 500 items — write (index, result) pairs to a JSONL file so you can resume from the last checkpoint after a crash. Add exponential backoff with jitter for 429 and 5xx errors. After 5 retries, move failed items to a dead letter list for manual inspection.Cost control: Before starting, estimate total cost using tiktoken. For 50K prompts at ~400 tokens each, that’s 20M tokens. At gpt-4o-mini pricing (0.15/1Minput,0.15/1M input, 0.60/1M output), that’s roughly $3-12 depending on output length. Consider the Batch API for 50% savings if 24-hour turnaround is acceptable.Monitoring: Log throughput (requests/sec), error rate, p50/p99 latency, and cumulative cost in real-time. Set an alert if error rate exceeds 5% — that usually indicates a systemic issue, not random failures.
What interviewers are testing: Understanding of distributed systems failure modes and the specific amplification risks with expensive API calls.Strong answer: The thundering herd occurs when many clients experience a failure simultaneously and all retry at the same moment, creating a spike that’s worse than the original load. With LLM APIs, this is especially dangerous because each retry costs real money.Scenario: Your application sends 100 concurrent requests. The provider returns 429 for all of them. If all 100 retry after exactly 1 second, you hit the API with 100 requests again — plus any new organic traffic. This creates a feedback loop where retries cause more rate limits which cause more retries.Solution 1 — Jitter: Add randomness to retry delays. Instead of “retry after 2^n seconds,” use “retry after 2^n * random(0.5, 1.5) seconds.” This spreads retries across the window. Full jitter (random between 0 and max delay) is even more effective than decorrelated jitter.Solution 2 — Circuit breaker: If error rate exceeds a threshold (e.g., 50% of requests failing), stop sending new requests entirely for a cooldown period. This prevents the retry storm from growing. After the cooldown, send a single probe request; if it succeeds, gradually ramp traffic back up.Solution 3 — Adaptive rate limiting with AIMD: On success, increase rate by a small constant. On failure, halve the rate immediately. This converges to the actual available capacity without oscillation — the same algorithm TCP uses for congestion control, and for the same reason.The key insight is that jitter alone isn’t enough at scale. You need all three layers working together: jitter smooths individual retries, circuit breaking prevents cascade, and adaptive limiting finds the new sustainable rate.
What interviewers are testing: Production engineering maturity around data loss prevention and clean shutdown semantics.Strong answer: Graceful shutdown means: stop accepting new work, finish in-flight work, save state for incomplete work, and exit cleanly. The goal is zero data loss — every prompt should either be fully processed with its result saved, or clearly marked as unprocessed for the next run.Implementation: Register a signal handler for SIGTERM/SIGINT that sets a shutdown flag. The producer stops feeding new items to the queue. Workers finish their current request (you cannot cancel an in-flight LLM call without losing the result and the cost). Set a shutdown timeout (e.g., 60 seconds) — if workers haven’t finished by then, log unfinished items and force exit.For the checkpointing layer, flush the current checkpoint immediately on shutdown signal. Any items that were dequeued but not yet processed go back to “pending” in the checkpoint file. On the next run, the checkpoint loader picks them up automatically.The tricky part is the race condition between “worker finishes request” and “checkpoint flushes.” Use an atomic counter for in-flight requests. The shutdown sequence waits until in-flight count reaches zero (with timeout), then flushes the final checkpoint, then exits.In Kubernetes, configure terminationGracePeriodSeconds to be longer than your longest expected LLM call (60-120 seconds). SIGTERM arrives first; SIGKILL follows after the grace period.
What interviewers are testing: Depth of understanding of Python’s async primitives beyond the basic tutorial level.Strong answer: These three serve different use cases for concurrent LLM requests.asyncio.gather(*tasks) runs all tasks concurrently and returns results in input order. Use it when you need results aligned with inputs (e.g., processing a CSV where row N’s result must go in output row N). Downside: you get no results until ALL tasks complete, so one slow response blocks everything.asyncio.as_completed(tasks) yields futures as they finish, regardless of input order. Use it when you want to process results as they arrive — for example, streaming partial progress to a UI, or writing results to a database as soon as each is ready. This is better for user-facing progress bars and for reducing peak memory (you can free each result immediately).asyncio.TaskGroup (Python 3.11+) is the modern replacement for gather() with better error handling. If any task raises an exception, it cancels all remaining tasks and raises an ExceptionGroup. This is the right choice when tasks are interdependent — if one fails, continuing the others is pointless (e.g., a multi-step RAG pipeline where step 2 depends on step 1).For batch LLM processing specifically: use as_completed with a semaphore for large batches (thousands of items) because you can checkpoint results incrementally. Use gather for small parallel operations (3-10 items) where simplicity matters. Use TaskGroup for orchestration workflows where partial completion is meaningless.