How to implement horizontal scaling of microservices
A microservice cannot be scaled horizontally if it retains conversational state in local JVM memory or on the local filesystem:
🟢 Junior Level
Horizontal Scaling (Scale Out) is the practice of increasing overall system capacity by adding multiple identical, stateless instances (replicas / pods) of a microservice behind a load balancer, as opposed to upgrading the CPU and RAM of a single machine (Vertical Scaling / Scale Up).
Vertical Scaling (Scale Up):
[Server 4 vCPU / 16 GB RAM] ──(Hardware replacement with downtime)──► [Server 32 vCPU / 128 GB RAM]
Horizontal Scaling (Scale Out):
┌──► [Pod 1 (4 vCPU / 16 GB)]
[Clients] ──► [Load Balancer / K8s Ingress] ───┼──► [Pod 2 (4 vCPU / 16 GB)]
└──► [Pod 3 (4 vCPU / 16 GB)]
Key Advantages over Vertical Scaling
- No Hardware Ceiling: Vertical scaling hits an inescapable physical and financial ceiling when maximum server hardware configurations are reached. Horizontal scaling enables connecting hundreds of standard cloud VM instances.
- High Availability (Fault Tolerance): If a vertically scaled server suffers a hardware or kernel crash, the entire system goes down. In horizontal scaling, if 1 pod out of 10 crashes, the load balancer reroutes traffic to the surviving 9 pods with zero downtime.
- Elastic Cost Optimization: In modern cloud platforms (Kubernetes, AWS ECS), instances are dynamically instantiated during peak traffic surges and terminated when traffic subsides, minimizing operational cloud costs.
[!NOTE] Supermarket Checkout Analogy: Vertical scaling is hiring one superhuman cashier who scans items twice as fast (eventually hitting physical human limits). Horizontal scaling is opening 10 checkout lanes side by side, allowing you to open 5 additional lanes during rush hour and close them when quiet.
🟡 Middle Level
1. The Core Prerequisite: Strict Statelessness (12-Factor App)
A microservice cannot be scaled horizontally if it retains conversational state in local JVM memory or on the local filesystem:
| Stateful Trap | Architectural Problem | Production Stateless Solution |
|---|---|---|
| In-Memory User Sessions | Request 1 lands on Pod 1 (sets session); Request 2 lands on Pod 2 $\to$ HTTP 401 Unauthorized! | Store sessions in a shared Redis Cluster (Spring Session Data Redis) or use stateless JWT Tokens. |
| Local File Uploads | Files saved to /tmp/uploads on Pod 1 are invisible to Pod 2. |
Stream uploads directly to distributed object storage (Amazon S3, Google Cloud Storage, or MinIO). |
| Background Cron Tasks | Spring @Scheduled tasks execute simultaneously on all 10 scaled pods, duplicating billing or emails! |
Guard schedulers with distributed locks (ShedLock) or migrate tasks to Kubernetes CronJob. |
Distributed Scheduler Coordination with ShedLock
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {
// Ensures only 1 pod out of 20 replicas executes the cron job:
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "hourlyBillingTask", lockAtLeastFor = "15s", lockAtMostFor = "5m")
public void processHourlyBilling() {
billingService.generateInvoices();
}
}
2. Kubernetes Horizontal Pod Autoscaler (HPA v2) Configuration
In Kubernetes, autoscaling is managed declaratively by the Horizontal Pod Autoscaler (HPA) controller:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 3
maxReplicas: 30
metrics:
# Metric 1: CPU Utilization
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
# Metric 2: Custom Prometheus Request Throughput (RPS)
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: 500
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 minutes before terminating pods (prevents flapping)
🔴 Senior Level
1. The Database Connection Pool Exhaustion Hazard
The most common production outage during aggressive horizontal autoscaling is the database connection ceiling:
[50 Spring Boot Pods] ──(50 pods × 20 Hikari connections = 1,000 DB sockets!)──► [PostgreSQL Database]
│
💥 max_connections = 500 reached!
(Database rejects connections; CRASH)
- Each Spring Boot pod defaults to
hikari.maximum-pool-size: 20. - During peak traffic, HPA scales pods from 5 to 50 replicas.
- 50 pods establish $50 \times 20 = 1,000$ active TCP sockets to PostgreSQL.
- PostgreSQL exhausts its configured
max_connections(typically 300–500), crashing the database and taking down all microservices across the enterprise.
The Senior Solution: Connection Pooling Proxies
Deploy a lightweight transaction-level connection pooler (PgBouncer or Odyssey for PostgreSQL, ProxySQL for MySQL, or AWS RDS Proxy) between the application pods and the database engine. PgBouncer multiplexes thousands of incoming application connections into a compact, stable pool of 50–100 physical database connections.
2. Event-Driven Autoscaling with KEDA (Kafka & RabbitMQ)
Standard CPU-based HPA fails for asynchronous messaging consumers:
- A Kafka consumer pod pulls batches of 500 events, writes to S3, and incurs high I/O wait with low CPU utilization ($\sim 10\%$).
- Meanwhile, an upstream surge causes the Kafka topic lag to spike from 0 to 5,000,000 unconsumed messages. HPA refuses to scale out because CPU remains low.
- The Solution: KEDA (Kubernetes Event-Driven Autoscaling) scales consumer pods directly based on queue lag:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: kafka-consumer-scaler
spec:
scaleTargetRef:
name: order-consumer-deployment
minReplicaCount: 2
maxReplicaCount: 40
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-cluster:9092
consumerGroup: order-processing-group
topic: order-events
lagThreshold: "500" # Add 1 worker pod for every 500 pending messages in lag
3. Graceful Shutdown & Zero-Downtime Teardown
When HPA terminates pods during scale-down, in-flight HTTP requests must not be aborted with connection reset errors:
- Kubernetes sends
SIGTERMto the pod and simultaneously removes the pod’s IP from the ServiceEndpointSlice. - Spring Boot stops accepting new requests and awaits completion of active requests:
server: shutdown: graceful # Waits for in-flight requests to complete spring: lifecycle: timeout-per-shutdown-phase: 30s # Hard limit before OS SIGKILL - A Kubernetes
preStophook introduces a brief delay (sleep 5), providing sufficient time forkube-proxyacross all cluster nodes to update theiriptables/IPVSrules before the container process begins terminating.
4 Tricky Questions
1. How do you horizontally scale a stateful WebSocket microservice?
Answer: WebSocket connections are inherently stateful: a client maintains a persistent, open TCP socket with a specific container pod. If User A is connected to Pod 1 and User B is connected to Pod 2, Pod 1 cannot directly write frames to User B’s socket. Architectural Solution:
- Load Balancing: Ingress proxies (e.g., NGINX / AWS ALB) are configured to support the HTTP
Upgrade: websockethandshake with long timeout thresholds. - Distributed Message Broker Backplane: All microservice pods subscribe to a shared Pub/Sub message broker (Redis Pub/Sub, RabbitMQ topic exchange, or Kafka). When Pod 1 needs to dispatch a message to User B, it publishes an event containing the recipient ID to the Redis topic. All pods receive the event; Pod 2 detects that User B holds an active local socket and transmits the message over the local TCP connection.
2. Why is autoscaling Java microservices on JVM Memory Utilization considered a dangerous operational trap?
Answer:
The Java Virtual Machine pre-allocates Heap memory on startup (-Xmx). Once the JVM allocates OS memory pages during runtime or after an initial traffic spike, it delays releasing physical memory back to the operating system until major garbage collection cycles occur.
If an HPA rule targets memory averageUtilization: 75%, after the first morning traffic wave, memory will sit permanently at 80–90% even if the application is completely idle. The HPA controller will falsely interpret this as sustained load and inflate the deployment to maxReplicas, keeping dozens of idle pods running indefinitely and wasting thousands of dollars in cloud infrastructure.
Best Practice: Autoscale Java microservices based on CPU utilization, HTTP Request Rate (RPS), or business metrics (Kafka lag).
3. How do you synchronize and invalidate local in-memory caches (Caffeine) when scaling across 20 pods?
Answer: If each pod maintains a fast in-memory Caffeine cache (e.g., caching product catalog prices), an update query routed to Pod 1 leaves stale data in the memory of the remaining 19 pods. Remedies:
- Two-Tier (L1/L2) Caching: L1 is a local Caffeine cache with a short TTL (e.g., 30–60 seconds); L2 is a centralized shared Redis cluster.
- Event-Driven Cache Eviction: Whenever an update occurs, the modifying pod publishes a
CacheInvalidationEvent(key)to a Redis Pub/Sub topic or Kafka broadcast channel. All 20 pods subscribe to this channel and immediately evict the invalidated key from their local Caffeine instances.
4. What is the Autoscaling “Flapping” (Thrashing) effect, and how do you protect against it?
Answer:
Flapping occurs when workload oscillates around the autoscaling threshold: CPU spikes to 85% $\to$ HPA adds 5 new pods $\to$ average CPU instantly drops to 40% $\to$ HPA terminates the 5 pods $\to$ CPU surges back to 85%. This continuous cycle of rapid pod scheduling and termination creates severe CPU thrashing (JVM JIT compilation churn) and overwhelms the Kubernetes API server.
Remedy: Configure the behavior.scaleDown.stabilizationWindowSeconds parameter (typically 300 seconds / 5 minutes). When the load subsides, the HPA controller monitors metrics for 5 continuous minutes of sustained low traffic before executing pod terminations, smoothing out transient traffic dips.
🎯 Interview Cheat Sheet
Must Know
- Definition: Adding identical stateless instances behind a load balancer to increase capacity and availability.
- The Stateless Axiom: Absolutely zero session state or file storage in JVM memory or local disk; offload to Redis Cluster and Amazon S3.
- Autoscaling Engines: Kubernetes HPA v2 for HTTP/CPU/RPS metrics; KEDA for event-driven message queue lag (Kafka/RabbitMQ).
- Database Hazard: Always deploy a database connection pooler (PgBouncer / AWS RDS Proxy) to prevent pod fleets from exhausting PostgreSQL
max_connections. - Java Trap: Avoid autoscaling on JVM Heap Memory (JVM does not immediately release memory back to the OS).
- Shutdown Protocol: Enforce Graceful Shutdown (
server.shutdown=graceful) with apreStopdelay.
Scale-Out Readiness Checklist
- Application is 100% stateless (JWTs or Redis for sessions).
- Background cron tasks are locked via
ShedLockto prevent duplicate execution across replicas. - Database connection pool is capped, fronted by a connection proxy.
- Liveness, Readiness probes, and Graceful Shutdown hooks are configured.
- Autoscaling scale-down includes a stabilization window (
stabilizationWindowSeconds: 300).
Common Follow-up Questions
- “How do you handle sticky sessions?” $\to$ Sticky sessions are an anti-pattern in cloud microservices; sessions should be stored in Redis or encoded in signed JWTs.
- “What is KEDA?” $\to$ Kubernetes Event-driven Autoscaling that scales pods based on message queue depth (Kafka lag) rather than CPU/memory.
- “How does Karpenter interact with HPA?” $\to$ HPA scales pods; when cluster nodes are full, Karpenter provisions new physical/VM nodes to host the new pods.
Red Flags (DO NOT Say)
- ❌ “Horizontal scaling means upgrading the server from 16 GB to 64 GB of RAM.” (That is Vertical Scaling; Horizontal Scaling adds more independent instances).
- ❌ “We scale our Spring Boot microservices based on JVM Memory percentage in HPA.” (JVM retains heap memory; HPA will freeze at maximum pods).
- ❌ “Each of our 50 pods opens 50 direct connections to PostgreSQL.” (Exhausts database connection limits; poolers like PgBouncer are required).
Related Topics
- What is Sharding — Database horizontal scaling
- What is the Difference Between Sharding and Partitioning — Partitioning models
- What is Database per Service Pattern — Microservice data isolation
- How to Ensure Fault Tolerance of Microservices — Resilience architectures