Scaling Stateless Applications
Horizontal scaling becomes significantly easier when application instances are interchangeable. A request can reach any healthy instance, failed instances can disappear without losing business state, and new capacity can join the fleet without synchronizing local files or sessions.
This is the core value of stateless application architecture. Stateless does not mean the application has no state. Production systems still depend on databases, caches, queues, object storage, and authentication state. The important distinction is that durable or shared state does not belong to an individual application instance.
Once compute becomes replaceable, load balancing, autoscaling, rolling deployments, multi-zone availability, and failure recovery become much simpler. The remaining challenge is ensuring that scaling the application tier does not merely move the bottleneck into databases, caches, queues, or external services.
Table of Contents
- What Stateless Really Means
- Externalizing Application State
- Horizontal Scaling and Load Balancing
- Protecting Downstream Dependencies
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
What Stateless Really Means
A stateless application instance does not depend on local information from previous requests in order to process the next request. Any healthy replica can handle traffic because required shared state is available outside the compute instance.
Consider an application that stores authenticated sessions and uploaded files on its local filesystem:
Load Balancer
/ \
/ \
v v
Instance A Instance B
session #123 session #456
file-a.pdf file-b.pdf
If the first request creates session #123 on Instance A and the next request reaches Instance B, Instance B cannot find the session. Sticky sessions can route the client back to Instance A, but the architecture remains coupled to that machine.
If Instance A fails, its sessions and locally stored files disappear with it.
A stateless architecture moves that state into shared systems:
Load Balancer
/ \
v v
Instance A Instance B
\ /
+-----+-----+
|
+-------------+-------------+
| | |
v v v
Database Cache Object Storage
|
v
Queue
Instances now contain application code and temporary execution state, but durable state survives independently. Either instance can process the next request.
This property enables several important production capabilities:
- Horizontal scaling: replicas can be added without migrating local state.
- Automatic recovery: failed instances can simply be replaced.
- Rolling deployments: old and new replicas can coexist during releases.
- Multi-zone operation: requests can move between failure domains.
- Load distribution: traffic does not need permanent affinity to individual machines.
Stateless architecture does not remove state management. It moves state to systems designed to manage it. This can increase dependency traffic and make database, cache, and storage capacity more important.
Externalizing Application State
Application state should be classified by its durability and access requirements rather than moved indiscriminately into one shared database.
| State | Typical Location | Reason |
|---|---|---|
| Business entities | Database | Transactions, queries, durability |
| Session state | Distributed cache or database | Shared low-latency access |
| Uploads | Object storage | Durability and independent scaling |
| Background work | Queue | Durable asynchronous processing |
| Temporary computed data | Distributed cache | Fast shared access with expiration |
| Request-local state | Application memory | Needed only for the current execution |
The distinction between request-local and application-instance-local state matters. Keeping a parsed request, database result, or temporary calculation in memory while processing one request is completely compatible with stateless architecture. Problems begin when future requests depend on that memory remaining on the same instance.
Sessions and Authentication
Authentication commonly introduces instance affinity accidentally. A server creates a session, stores it in memory, and returns a session identifier to the client. Horizontal scaling then requires every future request to find the instance that owns that identifier.
One solution is storing sessions in a shared low-latency store:
from dataclasses import dataclass
from datetime import timedelta
from uuid import UUID
@dataclass(frozen=True)
class Session:
user_id: UUID
account_id: UUID
class SessionRepository:
def __init__(self, cache: "Cache") -> None:
self.cache = cache
async def create(
self,
session_id: str,
session: Session,
) -> None:
await self.cache.set_json(
key=f"session:{session_id}",
value={
"user_id": str(session.user_id),
"account_id": str(session.account_id),
},
ttl=timedelta(hours=12),
)
async def get(self, session_id: str) -> Session | None:
value = await self.cache.get_json(
key=f"session:{session_id}"
)
if value is None:
return None
return Session(
user_id=UUID(value["user_id"]),
account_id=UUID(value["account_id"]),
)
Any API replica can now resolve the same session. The trade-off is that authentication requests depend on the shared session store, so its latency and availability become part of the request path.
Another approach uses cryptographically signed tokens containing sufficient authentication information for local validation. This removes a session-store lookup from many requests, but revocation, token size, expiration, permission changes, and key rotation require careful design.
Neither model is inherently stateless at the system level. The goal is that individual compute replicas do not own irreplaceable session state.
Files and Generated Data
Local files create similar coupling. Suppose an API generates an export and writes it to /tmp/export.csv. A later download request may reach another replica where the file does not exist.
Durable files should normally be stored outside application compute:
from uuid import UUID
async def complete_export(
export_id: UUID,
content: bytes,
) -> None:
object_key = f"exports/{export_id}.csv"
await object_storage.put(
key=object_key,
content=content,
content_type="text/csv",
)
await export_repository.mark_ready(
export_id=export_id,
object_key=object_key,
)
Temporary local disk remains useful for intermediate processing as long as the application can tolerate losing it when the instance disappears.
More about choosing storage models can be found here: Cloud Storage Patterns and Trade-Offs.
Horizontal Scaling and Load Balancing
Once instances are interchangeable, a load balancer can distribute requests across a changing fleet without tracking application state ownership.
Clients
|
v
Load Balancer
/ | \
/ | \
v v v
API 1 API 2 API 3
| | |
+----------+----------+
|
Shared Dependencies
Horizontal scaling adds replicas instead of making one server continuously larger. This provides both capacity and failure isolation, but it does not produce unlimited scalability.
If one instance can safely process 300 requests per second, ten instances do not necessarily provide exactly 3,000 requests per second. Shared dependencies can saturate first.
A useful capacity model is:
application_capacity =
healthy_instances
x safe_requests_per_instance
effective_system_capacity =
min(
application_capacity,
database_capacity,
cache_capacity,
downstream_capacity,
network_capacity
)
Scaling therefore requires monitoring saturation across the complete request path rather than only the application fleet.
Autoscaling Signals
CPU utilization is a common autoscaling metric, but it is not universally appropriate. A service spending most of its time waiting on databases or external APIs can saturate connection pools and request workers while CPU remains relatively low.
| Signal | Useful For | Limitation |
|---|---|---|
| CPU Utilization | CPU-bound services | Weak signal for I/O-bound workloads |
| Memory Utilization | Memory-heavy workers | May change slowly |
| Requests per Instance | HTTP APIs | Requests may have different costs |
| Concurrent Requests | I/O-heavy APIs | Requires meaningful concurrency limits |
| Queue Depth | Background workers | Does not represent job duration |
| Oldest Message Age | Queue latency objectives | Can react slowly to sudden bursts |
For HTTP services, request rate and concurrency can often provide better workload signals than CPU alone. For asynchronous workers, queue depth combined with message age and processing duration provides a clearer picture of backlog pressure.
Scaling must account for startup delay. If a new replica requires two minutes to download an image, initialize dependencies, and pass readiness checks, scaling only after saturation begins can produce several minutes of poor latency.
Minimum capacity provides another tool. Keeping several ready replicas costs more but provides immediate headroom for traffic bursts and instance failures.
Safe Scale-In
Removing capacity is more dangerous than adding it because active requests may still be executing on the instance selected for termination.
A safe scale-in sequence looks like this:
- Mark the instance as unavailable for new traffic.
- Remove it from load-balancer routing.
- Allow in-flight requests to complete within a bounded drain period.
- Stop accepting new asynchronous jobs.
- Return unfinished retryable jobs to their queue if necessary.
- Terminate the instance.
Applications should respond correctly to termination signals rather than disappearing immediately.
import asyncio
import signal
from fastapi import FastAPI
app = FastAPI()
shutdown_requested = asyncio.Event()
def request_shutdown() -> None:
shutdown_requested.set()
@app.on_event("startup")
async def configure_shutdown() -> None:
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, request_shutdown)
@app.get("/health/ready")
async def readiness() -> tuple[dict[str, str], int]:
if shutdown_requested.is_set():
# Stop receiving new traffic while existing
# requests are allowed to drain.
return {"status": "draining"}, 503
return {"status": "ready"}, 200
Production frameworks and orchestrators provide different lifecycle mechanisms, but the principle remains the same: traffic should stop before the process disappears.
Protecting Downstream Dependencies
Stateless compute can scale much faster than many stateful dependencies. This makes uncontrolled autoscaling a potential reliability problem.
Consider an API where every replica maintains a database connection pool of 30 connections:
5 replicas x 30 = 150 possible connections
20 replicas x 30 = 600 possible connections
100 replicas x 30 = 3,000 possible connections
Database maximum = 800
At 100 replicas, application autoscaling can exhaust the database even if each individual instance appears healthy.
The same problem affects external APIs, caches, queues, and other bounded dependencies.
Autoscaler
|
v
+----------------+
| 100 API replicas|
+--------+-------+
|
thousands of calls
|
v
+----------------+
| Shared Database|
| or External API|
+----------------+
|
X
overload
Several controls help protect downstream capacity:
- Connection pooling: reuse connections while keeping pool size bounded.
- Connection proxies: multiplex many application clients over fewer backend connections where appropriate.
- Concurrency limits: restrict expensive operations per replica or across the workload.
- Rate limiting: keep request rates within dependency quotas.
- Queues: buffer work when immediate downstream execution is unnecessary.
- Load shedding: reject lower-priority requests when resources are saturated.
- Backpressure: slow producers when consumers cannot keep up.
Autoscaling limits should therefore be derived partly from downstream capacity.
database_connections_available = 600
connections_per_replica = 20
database_safe_replica_limit =
600 / 20
= 30 replicas
This does not mean the fleet must always stop at 30 replicas. A connection proxy, smaller pools, database scaling, caching, or architecture changes may increase the safe limit. The important point is that maximum replicas should not be selected independently from dependency limits.
Failure handling is equally important. Slow dependencies cause requests to remain active longer, which increases concurrency and can trigger additional scale-out. Without timeouts and bounded retries, autoscaling can amplify a dependency outage. More about protecting services from these failure patterns can be found here: Circuit Breaker vs Bulkhead vs Load Shedding.
Production Design Example
Consider a cloud-hosted shipment platform receiving customer API traffic while generating labels, importing bulk shipments, and synchronizing tracking information with external carriers.
The synchronous API and asynchronous workloads scale independently:
Clients
|
v
Load Balancer
/ | \
v v v
API API API
\ | /
+-----+-----+
|
+------------+------------+
| | |
v v v
Database Cache Object Storage
|
v
Queue
/ | \
v v v
Worker Worker Worker
|
v
Carrier APIs
API replicas are stateless. Authentication state is either validated from signed credentials or retrieved from shared storage. Uploaded shipping documents live in object storage. Business entities live in the database.
Label generation and carrier synchronization run through queues. This prevents slow carrier APIs from occupying synchronous application capacity and allows worker fleets to scale separately from customer traffic.
API scaling uses request concurrency and CPU. Worker scaling uses queue backlog and oldest-message age. The maximum size of both fleets is constrained by database and carrier capacity.
Autoscaling Configuration
The following CloudFormation example illustrates target tracking for an application fleet. The specific cloud implementation is less important than the architecture: capacity changes automatically while minimum and maximum bounds remain explicit.
Resources:
ApiScalingTarget:
Type: AWS::ApplicationAutoScaling::ScalableTarget
Properties:
MinCapacity: 4
MaxCapacity: 30
ResourceId: !Sub service/${ClusterName}/${ApiServiceName}
RoleARN: !GetAtt AutoScalingRole.Arn
ScalableDimension: ecs:service:DesiredCount
ServiceNamespace: ecs
ApiCpuScalingPolicy:
Type: AWS::ApplicationAutoScaling::ScalingPolicy
Properties:
PolicyName: api-cpu-target
PolicyType: TargetTrackingScaling
ScalingTargetId: !Ref ApiScalingTarget
TargetTrackingScalingPolicyConfiguration:
TargetValue: 60
ScaleOutCooldown: 30
ScaleInCooldown: 300
PredefinedMetricSpecification:
PredefinedMetricType: ECSServiceAverageCPUUtilization
Scale-out is intentionally more responsive than scale-in. Extra capacity has a financial cost, but repeatedly removing and recreating capacity during fluctuating traffic increases latency and operational instability.
CPU would not necessarily be the only production signal. Request concurrency, load-balancer traffic, and application saturation metrics should be evaluated for the actual workload.
Failure and Deployment Flow
Stateless architecture changes the recovery model from repairing machines to replacing capacity.
If one API instance crashes:
- health checks fail;
- the load balancer removes the instance;
- other replicas continue receiving requests;
- the scheduler creates replacement capacity;
- the replacement initializes and passes readiness checks;
- traffic begins reaching the new replica.
No session or uploaded file needs to be copied from the failed instance.
The same property simplifies deployments:
Version 1
API API API API
|
| rolling deployment
v
Version 1 Version 2
API API API API
|
v
Version 2
API API API API
Old and new versions can coexist because requests are not permanently bound to individual instances. Database and API compatibility must still support the deployment window.
A failed release should stop before unhealthy replicas replace all known-good capacity. Readiness checks, deployment health thresholds, application error rates, and automated rollback provide stronger protection than merely verifying that new processes started.
Zone failure follows the same model at larger scale. Healthy replicas in another failure domain continue serving traffic while capacity is restored. More about failure-domain design can be found here: Designing Highly Available Cloud Systems.
Common Mistakes
Stateless application design is frequently undermined by small forms of hidden instance affinity or by autoscaling policies that ignore the rest of the system.
| Mistake | Production Impact | Better Approach |
|---|---|---|
| Keeping sessions only in process memory | Requests require sticky routing and sessions disappear when instances fail. | Use shared session storage or an appropriate signed-token model. |
| Writing durable uploads to local disk | Files disappear during replacement or become available only on one replica. | Move durable files to object or shared storage. |
| Using sticky sessions as the default scaling strategy | Traffic becomes uneven and instance failure disrupts affinity. | Remove unnecessary instance-local state and allow requests to reach any replica. |
| Autoscaling only on CPU | I/O-bound applications can saturate while CPU remains below the scaling threshold. | Use workload signals such as concurrency, request rate, or queue pressure. |
| Ignoring startup duration | New replicas arrive after traffic has already overwhelmed existing capacity. | Scale before saturation and maintain minimum headroom where required. |
| Scaling application replicas beyond database capacity | Connection exhaustion and query contention reduce total system throughput. | Derive safe scaling limits from downstream capacity. |
| Terminating replicas without draining | Scale-in and deployments interrupt active requests. | Remove instances from traffic before bounded graceful shutdown. |
| Making readiness depend on optional services | A minor dependency outage removes otherwise useful application capacity. | Keep readiness focused on dependencies required to serve critical traffic. |
| Assuming stateless means dependency-free | Shared state systems become hidden bottlenecks and failure points. | Capacity-plan and monitor every externalized state dependency. |
| Scaling faster during dependency degradation | More replicas generate more load against an already failing dependency. | Combine autoscaling with timeouts, concurrency limits, backpressure, and load shedding. |
Production Checklist
A stateless application should remain correct when instances are created, terminated, rescheduled, or replaced without warning.
- Remove durable local state: verify that terminating any instance cannot lose business data, sessions, uploads, or queued work.
- Test random instance termination: remove healthy replicas under traffic and verify that requests continue successfully.
- Measure per-instance capacity: load-test safe request rate, concurrency, CPU, memory, and connection usage.
- Select workload-specific scaling signals: use CPU, concurrency, request rate, queue depth, or message age according to actual saturation behavior.
- Reserve scaling headroom: account for startup time, traffic bursts, deployments, and instance failures.
- Bound database pools: calculate total possible connections at maximum fleet size.
- Protect downstream APIs: enforce concurrency limits, rate limits, timeouts, and bounded retries.
- Implement graceful draining: stop new traffic before terminating application processes.
- Make workers idempotent: ensure interrupted and redelivered jobs can execute safely.
- Validate readiness: route traffic only after instances can safely serve production requests.
- Monitor system-wide saturation: correlate application scaling with database, cache, queue, and downstream capacity.
- Exercise rolling deployments: verify that old and new replicas can coexist and unhealthy releases stop automatically.
Conclusion
Stateless architecture makes application compute disposable. Requests can reach any healthy replica, instances can fail without owning irreplaceable state, and capacity can expand or contract without synchronizing individual machines.
The architecture does not eliminate state. It moves business data, sessions, files, jobs, and shared temporary state into databases, caches, object storage, and queues. Those dependencies then become important scalability and reliability boundaries of their own.
Horizontal scaling is therefore an end-to-end capacity problem. Adding replicas improves application capacity only until another dependency becomes the bottleneck. Effective autoscaling combines workload-specific signals, sufficient startup headroom, safe scale-in, dependency protection, and explicit maximum capacity.
Key Takeaway: Make compute replaceable before trying to scale it. Externalize durable state, allow any healthy instance to process any request, protect shared dependencies from uncontrolled concurrency, and design scaling around the capacity of the complete system rather than the application tier alone.
Comments (0)