Resilience and Production Safety for Backend Services
A staff-engineer guide to idempotency, graceful shutdown, feature flags, and connection/thread-pool tuning in Spring Boot.
Resilience and Production Safety
Most outages are not caused by exotic bugs — they're caused by ordinary operations happening at the wrong moment: a retry hitting a non-idempotent endpoint twice, a rolling deploy killing pods mid-request, a connection pool sized by guesswork instead of math, a thread pool that queues forever instead of failing fast. This guide covers the production-safety patterns that separate services that degrade gracefully under stress from services that cascade into full outages: idempotency, graceful shutdown, feature flags, and pool sizing.
1. Idempotency: Making Retries Safe
A retry is only safe if the operation it repeats is idempotent — applying it twice produces the same end state as applying it once. Networks fail in the worst possible way for this: a client can time out on the response even though the server successfully processed the request, and then legitimately retry, hitting a service that already did the work.
Implementing idempotency keys
@RestController
@RequestMapping("/payments")
public class PaymentController {
private final IdempotencyStore idempotencyStore;
private final PaymentService paymentService;
@PostMapping
public ResponseEntity<PaymentResult> charge(
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody PaymentRequest request) {
Optional<PaymentResult> existing = idempotencyStore.find(idempotencyKey);
if (existing.isPresent()) {
return ResponseEntity.ok(existing.get()); // replay, no side effect
}
// Acquire a short-lived lock on the key to prevent concurrent duplicate processing
boolean acquired = idempotencyStore.tryLock(idempotencyKey, Duration.ofSeconds(30));
if (!acquired) {
throw new ConflictException("Request with this key is already being processed");
}
try {
PaymentResult result = paymentService.charge(request);
idempotencyStore.save(idempotencyKey, result, Duration.ofHours(24));
return ResponseEntity.ok(result);
} finally {
idempotencyStore.unlock(idempotencyKey);
}
}
}CREATE TABLE idempotency_keys (
idempotency_key VARCHAR(64) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
response_body JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);Store the request hash alongside the response, and reject requests that reuse the same idempotency key with a different payload — otherwise a client bug that reuses a key across unrelated requests silently returns the wrong cached response instead of erroring loudly.
Which operations need idempotency keys, and which are naturally idempotent
| Operation | Naturally idempotent? | Why |
|---|---|---|
PUT /users/42 {name: "Alice"} | Yes | Same result no matter how many times it runs |
DELETE /orders/9 | Yes (mostly) | Deleting an already-deleted resource is a no-op (return 404 or 204 either way) |
POST /payments (charge a card) | No | Each call is a new charge unless explicitly deduplicated |
POST /orders (create order) | No | Each call creates a new order unless deduplicated |
PATCH /inventory/sku-1 {delta: -5} | No | Relative updates are not idempotent — two retries decrement twice |
Prefer designing APIs around naturally idempotent operations where possible: PUT with a client-supplied absolute state, or PATCH with an absolute target value instead of a relative delta. Idempotency keys are the fallback for operations — like charging a card — that are inherently non-idempotent by nature.
2. Graceful Shutdown
When a pod is terminated — during a rolling deploy, an autoscale-down, or a node drain — Kubernetes sends SIGTERM, waits up to terminationGracePeriodSeconds, then sends SIGKILL if the process hasn't exited. What happens in that window determines whether in-flight requests complete cleanly or get dropped mid-response.
Enabling graceful shutdown in Spring Boot
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=25sWith server.shutdown=graceful, the embedded web server (Tomcat/Netty) stops accepting new connections on SIGTERM but allows in-flight requests up to the configured timeout to complete before the context closes.
# Deployment manifest
spec:
terminationGracePeriodSeconds: 35
containers:
- name: order-service
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]The preStop sleep is not optional in Kubernetes. SIGTERM delivery and Service endpoint removal happen concurrently, not sequentially — there's a real window where traffic can still be routed to a pod that has already begun shutting down, purely due to kube-proxy/iptables propagation lag across the cluster. A short preStop sleep (3-10s) before the app actually starts refusing traffic closes that race. Skipping it causes a small, steady trickle of connection-refused errors on every single deploy.
Draining, not just stopping
Graceful HTTP shutdown alone isn't enough if you also consume from a queue or hold long-lived connections. Each resource needs to drain in the right order:
@Component
public class GracefulShutdownCoordinator {
private final KafkaListenerEndpointRegistry kafkaRegistry;
private final ApplicationEventPublisher events;
@EventListener(ContextClosedEvent.class)
public void onShutdown() {
// 1. Stop pulling new work from the queue
kafkaRegistry.getListenerContainers().forEach(MessageListenerContainer::stop);
// 2. Flip readiness so the LB/mesh stops routing (if not already draining)
events.publishEvent(new AvailabilityChangeEvent<>(this, ReadinessState.REFUSING_TRAFFIC));
// 3. Let in-flight message processing finish (bounded by shutdown-phase timeout)
// 4. HikariCP pool close happens automatically via Spring's bean destruction order
}
}| Resource | Drain action | Risk if skipped |
|---|---|---|
| HTTP server | Stop accepting new connections, finish in-flight | Dropped in-flight responses |
| Kafka/RabbitMQ consumer | Stop polling, commit offsets for in-progress messages | Message redelivery or loss depending on ack timing |
| DB connection pool | Close only after in-flight queries finish | Connection reset mid-query |
Scheduled jobs (@Scheduled) | Let current run finish, don't start a new one | Partial batch writes |
3. Feature Flags for Production Safety
Feature flags decouple deployment from release — you can ship code to production dark, then turn it on for 1% of traffic, then 10%, then 100%, without a redeploy at each step. This is one of the highest-leverage tools for reducing blast radius.
@Service
public class CheckoutService {
private final FeatureFlagClient flags;
public Receipt checkout(Cart cart, String userId) {
if (flags.isEnabled("new-pricing-engine", userId)) {
return checkoutWithNewPricingEngine(cart);
}
return checkoutWithLegacyPricing(cart);
}
}public interface FeatureFlagClient {
boolean isEnabled(String flagKey, String targetingKey);
}
// A minimal in-house implementation backed by a config source you can hot-reload
@Component
public class ConfigDrivenFeatureFlagClient implements FeatureFlagClient {
private final AtomicReference<Map<String, FlagRule>> rules = new AtomicReference<>(Map.of());
@Override
public boolean isEnabled(String flagKey, String targetingKey) {
FlagRule rule = rules.get().get(flagKey);
if (rule == null) return false; // undefined flags default OFF, never ON
return rule.matches(targetingKey);
}
@Scheduled(fixedDelay = 30_000)
void refreshFromConfigSource() {
rules.set(configSource.fetchLatestRules());
}
}| Flag type | Purpose | Typical lifetime |
|---|---|---|
| Release flag | Gate a new feature during rollout | Days to weeks, removed after full rollout |
| Kill switch | Instantly disable a risky path without a deploy | Permanent, for anything with real blast-radius risk |
| Ops flag | Toggle operational behavior (e.g., disable a noisy background job during an incident) | Permanent |
| Experiment flag | A/B test | Duration of the experiment |
Flags that never get removed become their own liability — an "if" branch nobody remembers the purpose of, tested by nobody, multiplying your test matrix. Treat release flags as temporary by default: track them, set an expectation for removal after full rollout, and periodically audit for dead flags.
Build kill switches for anything that calls an unreliable external dependency, runs an expensive batch job, or was recently deployed. During an incident, "flip the flag off" is a 10-second mitigation; "roll back the deploy" is a 10-minute one.
4. Connection Pool Sizing with HikariCP
HikariCP is Spring Boot's default JDBC connection pool. Undersizing it causes requests to queue for a connection under load; oversizing it wastes database-side resources and, past a certain point, actually reduces throughput because the database spends more time context-switching between connections than doing work.
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=20
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.leak-detection-threshold=60000The sizing formula
HikariCP's own guidance, based on PostgreSQL's connection-pool sizing research, is a widely-cited starting formula:
pool_size = Tn × (Cm - 1) + 1
Tn = number of threads that can concurrently execute queries
Cm = number of concurrent connections/queries each thread ever holds (usually 1)
For a typical web service, this reduces to something close to: pool size ≈ core_count × 2 + effective_spindle_count, but the practical version most teams use is empirical: start with a modest pool (e.g., 10-20), watch hikaricp.connections.pending, and increase only if you observe real queueing under real load — not by guessing "bigger is safer."
A larger connection pool is not free throughput. Databases have a finite number of CPU cores to execute queries on. Beyond the point where connections roughly match available DB-side execution parallelism, additional connections just add context-switching and lock-contention overhead without increasing throughput — and can actively decrease it. If your service and 19 replicas all set maximum-pool-size=50 against a database with 16 cores, you can have 1,000 connections fighting over 16 cores.
Sizing across replicas, not per pod
| Consideration | What to check |
|---|---|
| Total connections across all replicas | replica_count × maximum-pool-size must stay under the database's max_connections with headroom for admin/migration connections |
| Database CPU cores | More pooled connections than cores rarely helps once queries are CPU-bound |
connection-timeout | How long a thread waits for a pooled connection before failing fast — should be short (1-5s), not the default assumption of "wait forever" |
leak-detection-threshold | Logs a warning if a connection is checked out longer than this — invaluable for catching code that forgets to close a Connection/Statement |
# leak-detection-threshold in action — logs this if a connection isn't returned in 60s
# com.zaxxer.hikari.pool.ProxyLeakTask : Connection leak detection triggered for
# com.zaxxer.hikari.pool.HikariProxyConnection@... on thread http-nio-8080-exec-7,
# stack trace followsMonitor hikaricp.connections.pending (threads waiting for a connection) as a leading indicator. Sustained non-zero values under normal load mean the pool is undersized or something is holding connections too long (e.g., a slow query, or a connection leak) — check leak-detection-threshold logs before just raising maximum-pool-size.
5. Thread Pool Tuning
Every async boundary in a Spring Boot service — @Async methods, ExecutorService-backed work, Tomcat's request-handling threads — is backed by a thread pool with its own sizing trade-offs. The same principle from connection pools applies: unbounded pools and unbounded queues turn overload into an OOM instead of a controlled, fast failure.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200); // bounded — not Integer.MAX_VALUE
executor.setThreadNamePrefix("notify-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}The default Executors.newCachedThreadPool() and an unbounded LinkedBlockingQueue are production hazards. A cached pool can spawn unlimited threads under sustained load, exhausting memory and native OS threads. An unbounded queue means a slow downstream dependency causes work to pile up in memory instead of failing fast — you'll OOM the JVM before you ever see a rejected-execution error telling you what's wrong. Always set explicit, bounded maxPoolSize and queueCapacity, and choose a RejectedExecutionHandler deliberately (CallerRunsPolicy to apply backpressure, or a custom handler that fails the request loudly).
Choosing pool size for I/O-bound vs CPU-bound work
| Workload type | Guidance | Rationale |
|---|---|---|
| CPU-bound (compression, serialization, hashing) | core_count to core_count + 1 | More threads than cores just adds context-switch overhead |
| I/O-bound (HTTP calls, DB queries with waiting) | Higher — often core_count × (1 + wait_time/compute_time) | Threads spend most time blocked waiting, not computing |
| Mixed / unknown | Start conservative, load test, watch queue depth and rejected-task metrics | Guessing without data is how you end up either starved or OOM'd |
# Tomcat's own request-handling thread pool — the other pool that matters
server:
tomcat:
threads:
max: 200
min-spare: 20
accept-count: 100 # OS-level backlog once max threads are busy
max-connections: 10000Tomcat's threads.max interacts directly with your HikariCP maximum-pool-size. If Tomcat can run 200 concurrent request threads but your DB pool only has 20 connections, 180 threads will be blocked waiting on a connection under load — which is often fine (that's exactly what pooling is for) as long as connection-timeout fails fast instead of hanging indefinitely, and the depth is visible in your dashboards.
Key takeaways
- Idempotency keys make retries safe for inherently non-idempotent operations (charges, order creation) — store the request hash alongside the cached response so reused keys with different payloads fail loudly instead of returning the wrong result.
server.shutdown=gracefulalone is not enough — pair it with apreStopsleep to cover the race between SIGTERM delivery and Service endpoint propagation.- Drain every stateful resource in order on shutdown: stop accepting new work first, let in-flight work finish, then close pools and consumers last.
- Feature flags decouple deploy from release and turn incident mitigation into a config change instead of a rollback — but treat them as temporary and audit for dead flags regularly.
- Bigger connection pools are not automatically better throughput — size against actual database CPU parallelism across all replicas combined, not per-pod guesswork.
- Always bound thread pool queues and pick a deliberate rejection policy; unbounded queues convert overload into a slow-motion OOM instead of a fast, visible failure.
- Watch
hikaricp.connections.pendingand thread-pool queue depth as leading indicators of saturation before they become customer-facing latency.
Interview Questions
- What makes an operation idempotent, and why do retries require it?
- How would you design an idempotency-key mechanism for a payment API? What happens if the same key is reused with a different payload?
- Walk through what happens between a
SIGTERMbeing sent to a pod and the pod actually being removed from traffic in Kubernetes. Where's the race condition, and how do you close it? - Why is a
preStophook with a short sleep often necessary even with graceful shutdown enabled? - In what order should a service drain its resources during shutdown — HTTP server, message consumers, DB pool — and why does the order matter?
- What's the difference between a release flag and a kill switch? Why might a team keep kill switches around permanently?
- What risk does an unbounded feature flag inventory create over time?
- Explain HikariCP's connection pool sizing formula. Why doesn't "bigger pool = more throughput" hold indefinitely?
- If
hikaricp.connections.pendingis climbing under load, what are the possible root causes, and how would you distinguish between them? - Why is
Executors.newCachedThreadPool()risky in a production service handling variable load? - What's the difference between CPU-bound and I/O-bound thread pool sizing, and why does that distinction matter?
- What does
leak-detection-thresholdin HikariCP help you catch, and what would you do after seeing that warning in logs? - Describe a
RejectedExecutionHandlerstrategy and when you'd chooseCallerRunsPolicyversus failing the request outright. - How do connection pool size, replica count, and database
max_connectionsinteract in a horizontally scaled deployment?