10-production-engineering-observability

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.

August 14, 2026
backend-engineeridempotencyfeature-flagsgraceful-shutdownconnection-poolinghikaricp

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

java
@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);
        }
    }
}
sql
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

OperationNaturally idempotent?Why
PUT /users/42 {name: "Alice"}YesSame result no matter how many times it runs
DELETE /orders/9Yes (mostly)Deleting an already-deleted resource is a no-op (return 404 or 204 either way)
POST /payments (charge a card)NoEach call is a new charge unless explicitly deduplicated
POST /orders (create order)NoEach call creates a new order unless deduplicated
PATCH /inventory/sku-1 {delta: -5}NoRelative 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

properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=25s

With 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.

yaml
# 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:

java
@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
    }
}
ResourceDrain actionRisk if skipped
HTTP serverStop accepting new connections, finish in-flightDropped in-flight responses
Kafka/RabbitMQ consumerStop polling, commit offsets for in-progress messagesMessage redelivery or loss depending on ack timing
DB connection poolClose only after in-flight queries finishConnection reset mid-query
Scheduled jobs (@Scheduled)Let current run finish, don't start a new onePartial 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.

java
@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);
    }
}
java
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 typePurposeTypical lifetime
Release flagGate a new feature during rolloutDays to weeks, removed after full rollout
Kill switchInstantly disable a risky path without a deployPermanent, for anything with real blast-radius risk
Ops flagToggle operational behavior (e.g., disable a noisy background job during an incident)Permanent
Experiment flagA/B testDuration 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.

properties
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=60000

The sizing formula

HikariCP's own guidance, based on PostgreSQL's connection-pool sizing research, is a widely-cited starting formula:

text
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

ConsiderationWhat to check
Total connections across all replicasreplica_count × maximum-pool-size must stay under the database's max_connections with headroom for admin/migration connections
Database CPU coresMore pooled connections than cores rarely helps once queries are CPU-bound
connection-timeoutHow 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-thresholdLogs a warning if a connection is checked out longer than this — invaluable for catching code that forgets to close a Connection/Statement
properties
# 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 follows
✅

Monitor 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.

java
@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 typeGuidanceRationale
CPU-bound (compression, serialization, hashing)core_count to core_count + 1More 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 / unknownStart conservative, load test, watch queue depth and rejected-task metricsGuessing without data is how you end up either starved or OOM'd
yaml
# 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: 10000
💡

Tomcat'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=graceful alone is not enough — pair it with a preStop sleep 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.pending and 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 SIGTERM being 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 preStop hook 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.pending is 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-threshold in HikariCP help you catch, and what would you do after seeing that warning in logs?
  • Describe a RejectedExecutionHandler strategy and when you'd choose CallerRunsPolicy versus failing the request outright.
  • How do connection pool size, replica count, and database max_connections interact in a horizontally scaled deployment?