Scaling AI Workloads Horizontally

Ma Mahalakshmi V Updated 19 Sep 2026
7 min read ·Lesson 197 of 224

Horizontal vs. Vertical Scaling

When a service cannot handle its current load, there are two fundamentally different ways to give it more capacity. Vertical scaling means giving a single instance more resources — a bigger CPU allocation, more memory. Horizontal scaling means running more instances of the same service side by side, with a load balancer distributing requests across them. Vertical scaling has a hard ceiling (there is always a biggest machine available) and a single point of failure (that one bigger machine can still crash). Horizontal scaling has a much higher practical ceiling and, done correctly, no single instance whose failure takes down the whole service.

For an OpenAI SDK application, horizontal scaling is usually the more natural fit, for a reason specific to this kind of workload: most of the time a request spends "in progress" is spent waiting on a network response from the OpenAI API, not consuming your own CPU. Adding more CPU to one instance (vertical scaling) does very little for a workload that is mostly waiting on I/O — what actually helps is running more instances that can each be waiting on their own independent request at the same time.

The Precondition: Statelessness

Horizontal scaling only works cleanly if any instance can handle any request — which requires the application to be stateless: no instance-specific data that a later request depends on. If your application stores session data, job status, or any user-specific state only in that instance's memory, a load balancer routing a follow-up request to a different instance will find that state missing.

# Anti-pattern: state lives only in this process's memory.
_in_memory_sessions: dict[str, dict] = {}


def save_session(session_id: str, data: dict) -> None:
    _in_memory_sessions[session_id] = data  # invisible to every other replica


def load_session(session_id: str) -> dict | None:
    return _in_memory_sessions.get(session_id)  # only works if the SAME instance handles both calls

This pattern works perfectly on a single instance and breaks the moment you run two. The fix is to move any state that must persist between requests into a shared, external store — a database, Redis, or the durable job queue from Lesson 7 — that every replica can read from and write to identically.

def save_session(redis_client, session_id: str, data: dict) -> None:
    redis_client.set(f"session:{session_id}", data_to_json(data), ex=3600)


def load_session(redis_client, session_id: str) -> dict | None:
    raw = redis_client.get(f"session:{session_id}")
    return json_to_data(raw) if raw is not None else None


def data_to_json(data: dict) -> str:
    import json
    return json.dumps(data)


def json_to_data(raw: bytes | str) -> dict:
    import json
    return json.loads(raw)

Passing redis_client in as a parameter, rather than reaching for a module-level global, keeps this consistent with the dependency-injection pattern used throughout this course — and it also means these functions can be tested against an in-memory fake with the same .set / .get interface, without a real Redis instance running.

Shared Rate Limits Across Replicas

This is the scaling concern most specific to AI workloads, and it is worth connecting directly back to Unit 12, Lesson 3, which covered client-side rate limiting for a single process. A rate limiter that tracks "requests made in the last minute" using an in-process counter works correctly for exactly one running instance. The moment you scale horizontally to, say, five replicas, each replica has its own independent counter — each thinks it is respecting the limit, but the actual number of requests hitting the OpenAI API is the sum across all five, potentially five times what any single replica believes it is sending.

# Anti-pattern at scale: this limiter only sees its own process's traffic.
import time


class LocalRateLimiter:
    def __init__(self, max_per_minute: int) -> None:
        self._max = max_per_minute
        self._timestamps: list[float] = []

    def allow(self) -> bool:
        now = time.monotonic()
        self._timestamps = [t for t in self._timestamps if now - t < 60]
        if len(self._timestamps) >= self._max:
            return False
        self._timestamps.append(now)
        return True

LocalRateLimiter is exactly correct for a single process and exactly wrong for a fleet of replicas sharing one underlying API quota. The fix is to move the counting into a shared store all replicas consult, the same way session state moved into Redis above:

def allow_request(redis_client, key: str, max_per_minute: int) -> bool:
    """A simple fixed-window limiter shared across every replica via Redis.
    INCR is atomic, so concurrent replicas cannot both observe a stale count."""
    current = redis_client.incr(key)
    if current == 1:
        redis_client.expire(key, 60)
    return current <= max_per_minute

redis_client.incr(key) atomically increments a shared counter and returns the new value — "atomically" here is the operative word: even if two replicas call this at the exact same instant, Redis guarantees each increment is applied one at a time, so the count is never lost or double-counted the way it could be with a naive read-then-write in application code. Setting an expiry (redis_client.expire(key, 60)) only the first time the key is created (current == 1) turns this into a rolling one-minute window that resets automatically, without a separate cleanup process.

This is the concrete, practical version of "shared rate limits across replicas": whatever rate-limiting strategy Unit 12 introduced for a single process, its counting mechanism has to move to a data store every replica shares once you scale horizontally — otherwise your actual aggregate request rate to the OpenAI API silently scales with your replica count, which can turn a carefully tuned rate limit into one that is exceeded by a wide margin the moment you add a second instance.

What to Scale On

Traditional autoscaling often triggers on CPU utilization: add instances when average CPU crosses some threshold. For an AI workload dominated by waiting on an external API, CPU is frequently a poor signal — a replica can be handling many concurrent requests, each mostly idle waiting for OpenAI's response, while its CPU usage stays low the entire time. Scaling only on CPU under this pattern means you will not scale out even while genuinely overloaded, because the metric that would tell you so never moves.

More useful signals for this kind of workload include:

  • In-flight request count or concurrency — how many requests each replica is currently handling, regardless of CPU usage.
  • Queue depth — for the architecture from Lesson 7, how many jobs are waiting to be picked up by a worker is a direct measure of whether current worker capacity is keeping up with demand.
  • P95/P99 request latency — a rising tail latency, even with average latency looking fine, often signals that some requests are queuing behind others faster than they can be served.
SignalGood fit for AI workloads?Why
CPU utilizationOften poorI/O-bound waiting on the OpenAI API does not show up as CPU load
Memory utilizationSometimes usefulRelevant if you buffer large responses or documents, otherwise a weak signal
In-flight concurrencyGoodDirectly reflects how much work each replica is actually handling right now
Queue depthGood, for worker fleetsDirectly reflects whether workers are keeping pace with incoming jobs
Request latency (p95/p99)GoodCaptures user-facing impact of being overloaded, not just resource usage

Common Mistakes

Storing per-user or per-session state only in an instance's local memory. This breaks the moment a load balancer routes a user's next request to a different instance, producing intermittent, confusing bugs that only appear in a multi-replica deployment and never show up when testing against a single local instance.

Rate-limiting only within a single process when running multiple replicas. As shown above, this silently multiplies your effective request rate to the OpenAI API by your replica count, which can produce a wave of 429 errors that seem to appear "out of nowhere" right after a scale-out event.

Autoscaling purely on CPU for an I/O-bound AI workload. This tends to under-scale during genuine overload (because CPU stays low while requests queue up waiting on external API responses) and can leave real users experiencing high latency while your monitoring shows healthy-looking CPU graphs.

Best Practices

Design every service intended to scale horizontally to be stateless, with all cross-request state — sessions, job status, rate-limit counters — held in a shared external store rather than a single process's memory.

Centralize rate limiting in a shared store (Redis or equivalent) as soon as you run more than one replica, so the limit reflects your true aggregate request volume to the OpenAI API rather than each replica's incomplete local view.

Scale on signals that reflect actual load for I/O-bound workloads — concurrency, queue depth, and tail latency — rather than defaulting to CPU utilization alone.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Scaling AI Workloads Horizontally and get answers drawn from it.

Signed-in readers only.