Concurrency & Asynchronous Performance Questions
Using parallelism, concurrency, and asynchronous execution to improve throughput and responsiveness. Covers thread pools, event loops, async/non-blocking I/O, contention and lock overhead, and the coordination costs that limit parallel speedup. Focuses on the performance implications of concurrency choices rather than concurrency correctness alone.
You run a real-time metrics stream that normally has ~1K consumers but suddenly spikes to 50K; producers continue sending but downstream consumers are slow. Describe immediate mitigations to avoid exhaustion and data loss, and long-term architecture changes (fanout, tiered caching, backpressure-aware proxies) to handle such consumer spikes while preserving data integrity.
Sample Answer
Immediate mitigations (first 0–30 minutes)
- Activate emergency throttles: apply rate limits at ingress (API gateway / producer proxies) to protect brokers. Prefer token-bucket limits per producer class to avoid global drop.
- Apply consumer-side filtering: instruct downstream teams to subscribe to reduced topic subsets or sampling (drop low-value metrics) via feature flags.
- Enable retention-first mode on brokers: switch from in-memory to disk-backed retention or increase segment size to avoid OOMs (Kafka: raise log.segment.bytes, flush more frequently).
- Scale horizontally stateless frontends and broker proxies (autoscale consumer-facing proxies) and add temporary read replicas for downstream reads.
- Backpressure signaling: enable consumer lag/alarm-driven dynamic throttling—if broker lag > threshold, push a “slow-consumer” signal to producers to reduce rates.
- Short-term shed: drop non-critical metrics (debug/tracing) with clear TTLs and log the decision for later reconciliation.
Why these work: they protect broker resources, reduce write amplification, and prioritize essential data while preserving integrity of high-value streams.
Long-term architecture changes
- Fanout layer: introduce a publish/subscribe fanout tier (e.g., Kafka + Kafka Streams / Pulsar + tiered storage or a dedicated fanout service) so producers write once and the fanout service handles distributing to many consumers asynchronously.
- Tiered caching + edge caches: add a multi-level cache (in-memory LRU at edge proxies, regional Redis/Memcached caches, and durable tiered storage). Consumers hitting caches reduce broker pressure.
- Backpressure-aware proxies: deploy proxy layer that implements protocol-aware backpressure (gRPC/HTTP2 or custom TCP) to translate consumer slowness into producer throttling or graceful degradation.
- Consumer groups & sharding: enforce consumer-group patterns and partition-consumer affinity so that heavy consumers don’t multiply reads; provide shared “fanout workers” for multi-tenant subscriptions.
- Durable buffering and replay: use brokers with durable tiered storage and long retention (cold storage) so slow consumers can catch up; provide replay APIs and retention policies per topic SLA.
- QoS and routing policies: tag streams with priority and SLOs; enforce QoS routing so critical metrics are never dropped.
- Observability & automation: add fine-grained metrics (producer rates, consumer lag, per-topic throughput), alerting on lag and autoscale runbooks that can apply mitigations automatically.
- Chaos / capacity testing: regularly run spike tests (chaos engineering) to validate throttles, autoscaling, and replay paths.
Trade-offs and considerations
- Throttling and shedding risk data loss for non-critical metrics—mitigate by classification and replay where possible.
- Caching increases complexity and eventual consistency; ensure TTLs and invalidation policies are clear.
- Fanout and proxies add latency and ops overhead; justify by SLA and cost analysis.
Example implementation notes
- Use Kafka + MirrorMaker or Kafka Streams for fanout; enable tiered storage (S3) for retention.
- Implement a lightweight proxy (Envoy with rate-limit and circuit-breaker filters) to enforce backpressure and routing.
- Expose a “reduce telemetry” feature flag to apps for controlled sampling during incidents.
This combination of immediate protective controls plus architectural changes preserves data integrity for critical metrics, contains blast radius from consumer spikes, and provides replay paths so slow consumers don’t cause permanent loss.
You own an async event ingestion service. Propose SLOs for availability, ingestion latency (e.g., p50/p95), and data loss, and define an error budget policy. Give concrete remediation steps engineers must take if the error budget is exhausted. Explain how queueing delays, retries, and overloaded consumers translate into SLO violations on downstream services.
Sample Answer
Situation / context (brief): For an async event ingestion service that accepts events, persists them, and dispatches to downstream consumers, we need clear SLOs and an error-budget policy so product and engineering balance feature velocity and reliability.
Proposed SLOs (concrete)
- Availability: 99.95% successful ingest API responses (HTTP 2xx) measured per 30-day window (≈22m downtime/month).
- Ingestion latency (end-to-end from client POST to persisted-ack):
- p50 ≤ 50 ms
- p95 ≤ 500 ms
- p99 ≤ 2 s (for alerts/diagnosis, not a primary SLO)
- Delivery latency to downstream (queue+consumer): p50 ≤ 200 ms, p95 ≤ 2 s
- Data loss: ≤ 0.001% of events (1 in 100k) per month; any loss triggers immediate severity-1 incident.
Error budget policy
- Error budget = 1 − availability SLO over 30 days; with 99.95% that’s 0.05% budget.
- Track budget consumption continuously; publish burn rate.
- Burn thresholds and responses:
- Green (<=25% used): normal development.
- Yellow (25–50%): limit non-critical releases, increase observability and capacity planning.
- Orange (50–90%): block optional features, prioritize reliability PRs, increase runbook readiness.
- Red (>90% or exhausted): immediate mitigation steps (below), weekly postmortem required for any production-impacting change until budget restored.
Concrete remediation when budget exhausted
- Freeze all non-critical deploys and feature flags that route new traffic.
- Immediate capacity actions: autoscale ingestion frontends, increase partition/consumer count, expand buffer/queue retention.
- Reduce input rate: enable client-side exponential backoff hints, soft-throttle non-essential producers, apply rate-limits to noisy tenants.
- Rollback recent releases suspected in burn; revert configuration changes.
- Prioritize engineering fixes: hotpatch backpressure, fix retry storms, resolve persistent errors.
- Post-incident: run blameless postmortem, update SLO/runbooks, and re-open releases only after budget positive for two consecutive windows.
How queueing delays, retries, overloaded consumers map to SLO violations
- Queueing delays: increased queue depth raises end-to-end delivery latency → violates delivery latency SLOs (p95/p99). If queue retention is finite, deep queues increase risk of eviction -> data-loss SLO breaches.
- Retries: exponential or unbounded retries amplify load (retry storms), increasing processing latency and can cause upstream quota exhaustion → availability SLO for ingestion degrades due to timeouts/errors.
- Overloaded consumers: slow consumers increase ACK latency, causing message backlog; backlog increases queueing delay and memory/disk pressure on brokers, potentially causing dropped messages or throttling, leading to both latency and data-loss SLO violations downstream.
- Feedback loops: retries + overloaded consumers create a feedback loop that rapidly burns error budget; observability signals to monitor: queue depth, consumer lag, retry rate, 5xx rate, and processing latency.
Key metrics & alerts to enforce policy
- Ingest success rate, p50/p95/p99 ingest latency, consumer lag, queue depth, retry rate, data-loss count.
- Alerts: sustained error budget burn rate > X (e.g., 2x expected) → auto-trigger yellow/orange actions; queue depth above threshold → scale/ throttle; data-loss → immediate P1.
This SLO + error-budget approach aligns engineering incentives with reliability while giving clear, actionable remediation steps when things go wrong.
Implement an asynchronous token-bucket rate limiter in Node.js with an async acquire() method that returns immediately if a token is available or waits until a token becomes available. Requirements: support burstSize and refillRate tokens/second, work correctly under high concurrency across many callers, and include a cancelable wait with timeout.
Sample Answer
Approach: implement a token-bucket keeping currentTokens (capped by burstSize) and lastRefill time. acquire() updates tokens on each call, returns immediately if tokens>0 else enqueues a Promise resolver. A single timer wakes queued waiters when enough tokens accumulate. Support cancel/timeout via AbortSignal and optional timeout ms. Node's single-threaded nature avoids races; careful bookkeeping ensures correctness under high concurrency.
class TokenBucket {
constructor({ burstSize, refillRate }) {
this.capacity = burstSize;
this.refillRate = refillRate; // tokens per second
this._tokens = burstSize;
this._last = Date.now();
this._queue = []; // [{resolve, reject, tokensNeeded, signal, timer}]
this._timer = null;
}
_refill() {
const now = Date.now();
const elapsedSec = (now - this._last) / 1000;
if (elapsedSec <= 0) return;
this._tokens = Math.min(this.capacity, this._tokens + elapsedSec * this.refillRate);
this._last = now;
}
async acquire({ signal = null, timeoutMs = null } = {}) {
if (signal && signal.aborted) throw new Error('aborted');
this._refill();
if (this._tokens >= 1) {
this._tokens -= 1;
return;
}
return await new Promise((resolve, reject) => {
const entry = { resolve: () => { clear(); resolve(); }, reject: (err) => { clear(); reject(err); } };
const clear = () => {
if (entry.timer) clearTimeout(entry.timer);
if (entry.unsub) entry.unsub();
};
if (timeoutMs != null) {
entry.timer = setTimeout(() => entry.reject(new Error('timeout')), timeoutMs);
}
if (signal) {
entry.unsub = () => signal.removeEventListener('abort', onAbort);
const onAbort = () => entry.reject(new Error('aborted'));
signal.addEventListener('abort', onAbort);
}
this._queue.push(entry);
this._scheduleTimer();
});
}
_scheduleTimer() {
if (this._timer || this._queue.length === 0) return;
const now = Date.now();
this._refill();
if (this._tokens >= 1) {
// fulfill as many queued requests as tokens allow
while (this._tokens >= 1 && this._queue.length) {
this._tokens -= 1;
const e = this._queue.shift();
e.resolve();
}
if (this._queue.length === 0) return;
}
// compute ms until next token
const tokensNeeded = 1 - this._tokens;
const ms = Math.max(0, Math.ceil((tokensNeeded / this.refillRate) * 1000));
this._timer = setTimeout(() => {
this._timer = null;
this._scheduleTimer();
}, ms);
}
}
Key points:
- O(1) per acquire amortized; queue length bounded by callers waiting.
- Correct under high concurrency because operations are atomic in event loop.
- Supports abort via AbortSignal and timeout.
Edge cases: very small refillRate, burstSize 0, callers that cancel — queue cleanup prevents leaks.
As an SRE, you need to choose between an async single-threaded model (e.g., Node.js, Python asyncio) and a multi-threaded model for a new high-throughput HTTP service. Describe the trade-offs in terms of latency, throughput, memory usage, CPU utilization, handling blocking syscalls, debugging complexity, and ecosystem maturity. What criteria would cause you to pick one model over the other?
Sample Answer
High-level decision: pick async single-threaded for highly concurrent, I/O-bound workloads where per-request CPU work is small and low memory footprint matters; pick multi-threaded for CPU-bound work, heavy blocking syscalls, or when you need simpler programming/debugging and mature threading libraries.
Trade-offs
- Latency: Async excels at low median latency under high concurrency because it avoids thread scheduling overhead. However, single-threaded event loops can suffer head-of-line blocking (long callback/GC pauses) and worse tail latency unless carefully engineered. Multi-threaded systems can isolate long requests across threads, improving tail latency if you have enough cores.
- Throughput: For I/O-bound work, async gives higher throughput per-core. For CPU-bound work, multi-threading (or multi-process) scales better across CPU cores.
- Memory usage: Async uses far less memory per concurrent connection (no per-request thread stack). Threads carry stack and runtime overhead; many threads → high memory.
- CPU utilization: Async utilizes a single core unless you shard processes; multi-threaded uses multiple cores naturally. For fully utilizing multi-core machines, threads (or languages with green-thread multiplexing like Go) are simpler.
- Handling blocking syscalls: Async requires non-blocking libraries or offloading to worker thread pools; unexpected blocking calls can stall the event loop. Threads let blocking syscalls run without stopping other threads.
- Debugging complexity: Async code (callbacks/coroutines, state machines) can be harder to reason about, trace, and profile; tooling for threads is generally more mature and conceptually simpler.
- Ecosystem maturity: Node.js and asyncio are mature for I/O workloads; Java, C++, and Go have rich threading models and performance tooling. Consider library support for non-blocking drivers (DB, gRPC, TLS).
Decision criteria (pick async when)
- Workload is overwhelmingly I/O-bound with short request handling.
- Need to support millions of concurrent idle connections (e.g., websockets, long-poll).
- Memory per-connection must be minimal.
- Non-blocking drivers exist for your stack.
Decision criteria (pick multi-threaded when)
- Requests perform significant CPU work or blocking system calls.
- Predictable tail latency is critical and you can provision cores.
- Team prefers simpler concurrency model / existing threaded ecosystem (Java, C++).
- Need easy use of blocking libraries or mature profiling tools.
Hybrid approaches
- Use async front-end (high concurrency) + thread/process pool or separate worker service for CPU-heavy or blocking tasks.
- Use multi-process scaling (container pods) to combine async per-process efficiency with multi-core utilization.
Operational considerations
- Monitoring: instrument event-loop latency, GC pauses, thread pool saturation.
- Deploy: stress-test tail latency, simulate blocking calls.
- Failure modes: plan circuit breakers and request timeouts to prevent resource starvation.
This decision should be driven by measured workload characteristics, available libraries, and SLOs for latency, throughput, and cost.
Describe approaches to unit and integration testing for asynchronous server code across Node.js (async/await), Python asyncio, and Java CompletableFuture/Reactive. Include strategies to make tests deterministic, mocking timers and I/O, simulating backpressure, and reducing flakiness due to concurrency. Provide example test patterns for asserting ordering and timing behavior.
Sample Answer
High-level approach
- Unit tests: isolate async logic, mock I/O (HTTP, DB, filesystem), fake timers, assert state and call ordering.
- Integration tests: run real components (or Testcontainers) but use deterministic inputs, fixed clocks, and network shaping to simulate latency/backpressure.
- Reduce flakiness: avoid sleeps, prefer hooks that advance virtual time or await deterministic completion events; limit shared global state; run tests in single-threaded modes where possible.
Node.js (async/await)
- Tools: Jest (useFakeTimers), Sinon, nock (HTTP), mock-fs, Testcontainers for integration.
- Fake timers to make timers deterministic:
// jest
jest.useFakeTimers();
const p = myAsyncTask(); // uses setTimeout internally
jest.advanceTimersByTime(1000);
await p;
- Mock I/O (nock for HTTP). Simulate backpressure by using Node streams and controlling .pause()/resume() in tests to assert upstream buffering and ordering.
Python asyncio
- Tools: pytest-asyncio, asynctest/pytest-mock, aioresponses (HTTP), pytest-mock for mocks.
- Deterministic time: inject an abstract clock or use freezegun for datetime + monkeypatch loop.time or use asyncio.Event to drive progress.
# pytest-asyncio pattern
async def test_ordering(monkeypatch):
events = []
async def task():
await asyncio.sleep(1)
events.append('done')
# monkeypatch asyncio.sleep to advance virtual clock or stub
await task()
assert events == ['done']
- Simulate backpressure by limiting stream buffer sizes (StreamReader) or using custom producer that awaits consumer-driven Event().
Java (CompletableFuture / Reactive)
- CompletableFuture: unit-test by completing futures explicitly or using CompletableFuture.completedFuture; avoid real threads—use direct executor.
CompletableFuture<String> f = CompletableFuture.supplyAsync(supplier, Runnable::run); // sync executor
- Reactive (Reactor/RxJava): use virtual time and StepVerifier.withVirtualTime to assert timing/order and simulate backpressure via request(n).
StepVerifier.withVirtualTime(() -> flux.delayElements(Duration.ofMillis(100)), () -> VirtualTimeScheduler.getOrSet(), 0)
.thenAwait(Duration.ofMillis(300))
.expectNextCount(3)
.thenCancel()
.verify();
Strategies to reduce flakiness
- Replace real sleeps with virtual time or await well-defined completion signals (events, latch, futures).
- Limit parallelism in tests; run concurrency-sensitive tests serially.
- Inject test hooks (clock, executor, buffer limits) to control behavior.
- Use deterministic executors (synchronous or single-threaded) and mock network/DB.
Asserting ordering and timing
- Ordering: capture events (timestamps or sequence IDs) and assert exact sequence.
- Timing: use virtual-time schedulers or fake timers and advance time deterministically, then assert elapsed or number of fired timers rather than real-world wall-clock durations.
For SRE context: include integration tests with network chaos (tc/netem) in controlled CI jobs, and guard flaky tests with thresholds and retries only when instrumentation proves nondeterministic.
Unlock Full Question Bank
Get access to all 40 Concurrency & Asynchronous Performance interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.