Tradeoffs
58 scenarios, canonical answer and when the opposite is right.
Sorted by interview frequency
You're building a banking ledger with multi-row transactions and complex reports across accounts, transactions, and customers.
Multi-row atomicity, consistency, isolation, durability (ACID) transactions + ad-hoc joins/reports are exactly what relational engines exist for. NoSQL forces app-side joins and either denormalization or distributed transactions.
If access patterns were a small fixed set keyed by account_id and you needed extreme write throughput, DynamoDB-style design would win.
A user uploads a video that must be transcoded into 5 resolutions before being playable.
Transcoding takes minutes, far exceeding any reasonable HTTP timeout. Queue + worker pool decouples upload from processing and allows retries.
For sub-second operations where the caller needs the result immediately, async adds complexity (polling, callbacks) without benefit.
Small team (8 engineers) building a new B2B SaaS product.
Microservices add operational complexity (deploy, observability, distributed tx) that an 8-person team rarely has capacity for. Keep boundaries clear in code; split later if scaling demands.
When team count grows to where independent deploys and language flexibility become bottlenecks, splitting along domain boundaries pays off.
Read-heavy workload where staleness for a few seconds is tolerable.
Cache-aside is simpler, the cache only stores what's actually read, and the app stays the source of truth. Time-to-live (TTL) handles staleness.
If reads must always be consistent with the latest write and write volume is moderate, write-through avoids the cache-miss latency spike after updates.
Multiple users may edit the same record, but concurrent edits are rare. You're choosing the concurrency-control model.
When conflicts are rare, optimistic locking lets writers proceed without blocking, retries only the unlucky few, and surfaces the conflict to the user. Pessimistic locks serialize every editor and tie up connections for slow, human-paced edits.
Hot rows where conflicts are the norm, like decrementing inventory in a flash sale, turn optimistic locking into a retry storm. Short pessimistic locks (SELECT ... FOR UPDATE) are cheaper there.
You need to fan out user events to ~10 downstream services and let each independently replay the last 7 days for backfill.
Kafka's append-only log + consumer groups + offset management is built for multi-subscriber fan-out with replay. SQS is a point-to-point queue; once consumed, messages are gone.
For a single worker fleet processing a simple work queue with no replay need, SQS is far simpler and cheaper.
You're designing internal service-to-service APIs across polyglot backends with strict latency service level agreements (SLAs) and schema evolution.
gRPC has lower latency (HTTP/2 + binary protobuf), enforced schemas via .proto, codegen for many languages, and built-in streaming.
For public-facing APIs consumed by browsers and third parties, REST/JSON is the dominant standard with better tooling and discoverability.
A collaborative editor needs sub-100ms updates across users.
WebSockets give true bidirectional push with low overhead. Long polling adds latency, reconnection cost, and load balancer headaches.
If updates are infrequent (every few minutes) and clients are behind hostile proxies, long polling is more reliable and simpler.
Single Postgres instance approaching CPU saturation as traffic grows.
Vertical is the simplest first step for relational DBs: no sharding, no app changes. Modern cloud DBs scale to enormous sizes.
Once you hit the largest instance available or write-throughput limits, horizontal (read replicas, sharding) becomes necessary.
Designing a fleet of API servers that need to scale horizontally based on traffic.
Stateless servers scale and recover trivially: any instance handles any request, autoscaling is straightforward, rolling deploys are safe.
Real-time apps (game servers, video conferencing) where state is heavy and latency matters often pin clients to a stateful node.
Sharding 100GB of session data across 10 cache nodes, with occasional node additions/removals.
Modulo remaps ~all keys when N changes. Consistent hashing moves only ~1/N of keys, preventing cache stampedes during scaling.
If the node count is truly fixed forever, modulo is simpler and has perfectly uniform distribution.
Mobile clients on flaky networks need to fetch a screen's worth of nested, related data in one round trip.
GraphQL lets the client request exactly the fields it needs in one query, eliminating waterfall round trips and over-fetching on slow networks.
When response shapes are stable, caching matters (HTTP cache), and team familiarity favors REST, GraphQL's complexity (N+1, auth, query cost) isn't worth it.
Analytics dashboard scanning aggregations over billions of rows but only 3-4 columns at a time.
Columnar stores read only the queried columns, compress them extremely well, and vectorize aggregations, orders of magnitude faster for analytics.
For online transaction processing (OLTP) workloads with frequent row inserts/updates and lookups by primary key, row stores win on write latency and point reads.
Write-heavy app deployed to two regions for low-latency writes in both.
Single leader forces one region's writes to cross-region, defeating the latency goal. Multi-leader allows local writes; you accept conflict resolution complexity.
If consistency outweighs latency (e.g., financial ledger), keep single leader and accept the cross-region write hit.
An order checkout spans cart, payment, inventory, and shipping services, each with its own database.
Two-phase commit (2PC) holds locks in every participant until the coordinator decides, blocks if the coordinator fails mid-protocol, and couples independently deployed services and teams. A saga runs each step as a local transaction and undoes completed steps with compensations, so services stay autonomous at the cost of eventual consistency.
Inside one trusted boundary, such as shards of one database engine (Spanner) or one app using MySQL XA, 2PC is workable and gives true atomicity. Sagas trade that atomicity for availability and independence.
You need a cache that also supports rate-limit counters, sorted sets for leaderboards, and pub/sub for notifications.
Redis ships rich data structures (sorted sets, hashes, streams) and pub/sub. Memcached is purely string key-value (KV).
If you genuinely only need a least recently used (LRU) string cache and want maximal memory efficiency + simplicity, Memcached has lower overhead.
Public blog/marketing site that needs SEO and fast first-paint on mobile.
SSR/SSG ships rendered HTML for instant first paint and reliable SEO. Pure SPAs delay both behind a JS bundle download + execution.
Highly interactive logged-in apps (dashboards, editors) where SEO doesn't matter often prefer SPAs for richer client-side state.
Real-time multiplayer game where player positions update 60×/sec.
Stale position packets are useless: you want the newest, not retransmits of old ones. UDP avoids head-of-line blocking from packet loss.
For chat, file transfer, or game inventory updates where every byte must arrive in order, TCP reliability is required.
User requests account deletion in a system that powers downstream analytics.
GDPR/privacy expectations require actual removal of personal data. Soft delete keeps the data, which becomes a liability.
For business documents (invoices, audit records) where you must retain history, soft delete with role-based visibility is correct.
Mid-size startup with shared libraries across 12 services, frequent cross-cutting changes.
Atomic cross-service changes, single source of truth for shared libs, unified tooling. Frequent cross-cutting changes are painful across polyrepos.
Independent teams shipping totally decoupled services (especially with different release cadences or open-source components) often prefer polyrepo.
A URL shortener counts every click for its analytics dashboard, and owners can edit or expire a link's destination. Which status code should GET /:code return?
Browsers cache a 301 and go straight to the destination on repeat visits, so those clicks never reach your click stream and a later edit to the link never takes effect for that browser. A 302 sends every click back through the service (or a CDN whose edge logs you collect), which keeps counts accurate and destinations editable.
If links never change and you don't count clicks, a 301 saves the extra round trip on repeat visits and takes load off the redirect service.
A hot counter is hit by 64 threads thousands of times per second. You need correctness without crushing throughput.
For single-word updates, CAS avoids the syscall and context-switch cost of mutex contention and scales linearly with CPU count up to the point where CAS retries dominate. Mutexes win once the critical section grows to multiple statements.
If the critical section is several reads and writes that must be atomic together, a mutex is simpler and faster than a hand-rolled CAS loop.
A service makes ~5 downstream HTTP calls per request, 80% of request time is waiting on I/O, and you expect 50k concurrent connections.
I/O-bound workloads with high concurrency don't need a thread per in-flight call, the threads just sleep on epoll. An event loop drives tens of thousands of concurrent I/Os with a handful of OS threads, cutting memory and context-switch overhead.
For CPU-bound work, threads (one per core) win, async buys nothing when nobody is sleeping.
You're locking a 1M-entry in-memory map. One big lock is simple but throughput is poor under writes.
Striped / fine-grained locks let unrelated keys be updated concurrently, under uniform keys you get roughly N-way write parallelism. This is exactly how Java's pre-Java-8 `ConcurrentHashMap` worked.
If operations frequently touch many keys (`putAll`, atomic multi-key updates) a single lock is simpler and avoids deadlock from acquiring multiple stripes.
Your service issues many DB and HTTP calls per request. You're choosing the I/O style for the platform.
Non-blocking I/O scales connection count and concurrency far past what threads can, Netty / Tokio / asyncio routinely drive 100k connections per box. Threads cost ~1MB of stack each and context-switch overhead grows with the pool.
Simple internal tools or batch jobs with low concurrency, sync code is dramatically easier to reason about and debug. The cognitive tax of async isn't worth it under low load.
Shopping cart service for a global e-commerce site. Users add items from mobile, web, and tablet; some sessions are offline.
Cart UX tolerates 'union of all adds' semantics, and users hate write failures. The original Dynamo paper picked exactly this trade for Amazon's cart. CRDTs make merges deterministic.
For an inventory decrement or a bank balance, strong consistency wins: silently merging two debits is worse than rejecting one.
Primary Postgres in us-east-1 replicating to a us-west-2 standby for DR. Writes are user-facing checkout transactions.
Cross-region sync replication adds ~70ms to every commit, killing checkout latency. Async accepts a small RPO (recovery point objective) window of unreplicated writes for big latency wins.
Financial ledgers with zero-data-loss requirements pick sync (or quorum sync to 2 of 3 standbys). Spanner's TrueTime-based sync replication is the gold standard.
Write-heavy time-series workload, 100k writes/sec, can tolerate eventual reads, must stay available during single-AZ outages.
Leaderless gives write availability during partitions and scales writes horizontally without re-electing a leader. Quorum tuning controls consistency per op.
If writes must be linearizable and you need cross-row transactions, leader-based (or Spanner-style sharded Paxos) is the right pick.
A user-profile service with N=3 replicas per key, light contention, multi-region.
Quorum survives a replica down without failover; single-master needs leader election (seconds of unavailability) on every primary loss. For multi-region, quorum sidesteps the WAN-pinned leader.
If you need linearizable reads and cross-key transactions, a single master (or Raft group) is much simpler than gluing quorum + version vectors + repair.
Tracking which of ~1500 worker nodes are alive in a batch-processing cluster.
Raft membership churn at 1500 nodes saturates the leader; you do not need linearizable liveness, just convergence within seconds. SWIM gives sub-second failure detection at constant per-node cost.
For a small, security-critical control plane (10s of nodes) where 'who is in the cluster' must be strongly consistent and auditable, Raft membership wins.
Storage engine for a write-heavy time-series ingest service. Reads are mostly recent data; durability matters; SSDs.
LSM converts random writes into sequential SSTable flushes plus background compaction, which is a much better fit for write-heavy ingest. Reads of recent data hit the memtable and L0, so read amplification stays modest.
Mixed OLTP with heavy in-place updates to the same rows and strict read latency SLOs favor B-trees. LSM read amp and compaction stalls become liabilities.
User-profile service joins users, preferences, and subscription rows on almost every read. Writes are infrequent.
Read-heavy, write-light workloads pay the denormalization cost (extra storage, update fan-out) only on rare writes, and amortize it across many cheap single-row reads. Joins disappear from the hot path.
Write-heavy systems with many places to update on a single change (think e-commerce inventory) usually keep normalization to avoid update anomalies and write amplification.
OLTP service mostly does single-row updates with the occasional multi-row consistency check (e.g., 'don't double-book a seat').
READ COMMITTED is the cheapest correct default for the common case. Pay for SERIALIZABLE (or explicit row locks / unique constraints) only on the few transactions that actually need protection from write skew or phantoms.
If multi-row invariants are pervasive (financial postings, scheduling), running everything at SERIALIZABLE (Postgres SSI) is simpler than reasoning about which transactions need SELECT FOR UPDATE.
A reporting team wants to run hour-long aggregate queries against the same Postgres serving your checkout flow.
OLTP engines are tuned for short row-oriented transactions; long scans contend with the buffer cache, pollute the plan cache, and (under MVCC) hold back the xmin horizon, bloating tables. OLAP stores are columnar, vectorized, and built for this.
Small scale or early-stage products can absolutely run reports on a read replica; introducing a warehouse before you need one is over-engineering.
Choosing between Postgres-style heap tables and MySQL/InnoDB-style index-organized tables (clustered on PK) for a new schema.
When most reads are by PK or PK-range, clustering rows in PK order makes the leaf pages the rows. Range scans are sequential and cache-friendly, and the PK index has zero extra indirection.
Workloads dominated by secondary-index lookups suffer in IOT, because secondary indexes store the PK (not a row pointer) and need a second lookup. Heap tables with multiple covering secondary indexes win there.
Postgres `synchronous_commit`: leave it ON (fsync WAL before ack) or set it OFF (ack early, fsync later) to chase throughput.
Default ON is the only setting that guarantees a committed transaction survives a crash. The right way to claw back throughput is batching (group commit), faster storage, or SSDs with battery-backed cache, not silently losing the last N transactions.
Workloads where the last few seconds of writes are truly disposable (telemetry, analytics ingest with replay) can flip it off for big throughput gains. Be explicit about the data-loss window.
Serving a global audience over flaky mobile networks. Pick HTTP 2 or HTTP/3 at the edge.
QUIC streams are independent at the transport layer, so a single dropped packet does not stall every concurrent stream like it does on TCP. QUIC also has 0-RTT resumption and connection migration across IP changes (Wi-Fi to LTE).
Inside the data center on reliable low-loss links, HTTP/2 over TCP is simpler, more debuggable, and friendlier to middleboxes. UDP is also more likely to be rate-limited by corporate firewalls.
Returning clients are hitting your API. Do you allow TLS 1.3 0-RTT or require a full 1-RTT handshake?
0-RTT early data is replayable by an attacker that captures it. For mutations, payments, or anything non-idempotent, that is unsafe. The 1 extra round trip is worth the safety.
For idempotent GETs on a latency-critical read path (search, feed, static APIs), enable 0-RTT and bound the replay window with an anti-replay cache.
Server expected to hold 100k concurrent long-lived connections. Thread-per-connection or event loop?
Threads cost ~1MB of stack and a kernel-scheduled context each; 100k threads is 100GB of stack and ruinous context-switch overhead. Event loops watch many fds from one (or N=cores) threads with O(1) ready notification.
For short CPU-heavy requests with few concurrent connections, thread-per-request is easier to reason about and lets blocking calls (sync DB drivers, CPU work) stay on their own thread. Java's virtual threads (Loom) blur the line further.
Running untrusted plugin code or risky native libraries. Process or thread?
Processes have separate address spaces; a segfault, memory corruption, or OOM kills only that process. Threads share memory, so one bad pointer takes down the whole server. Chrome and modern browsers run each tab in its own process for this reason.
If you need cheap shared memory and frequent communication (e.g., a worker pool over a hot in-memory cache), threads avoid IPC overhead and fork cost. Trust boundary matters more than performance.
Database is doing its own page cache and you do not want the kernel double-buffering. Buffered or direct I/O?
When the app already manages its own buffer pool (Postgres, MySQL, RocksDB, ScyllaDB), the kernel page cache is wasted RAM and double work. O_DIRECT bypasses it and gives the DB deterministic control over what is cached.
For file servers, log shippers, or apps that benefit from OS read-ahead and free shared caching across processes, buffered I/O is faster and simpler. O_DIRECT requires aligned buffers and is unforgiving.
Load balancing across backends where request durations vary wildly (some 1ms, some 5s). Round robin or least connections?
Round robin assumes equal-cost requests. With heavy-tailed durations it piles slow requests on whichever backend got unlucky. Least-connections routes new work to the least-loaded backend, smoothing tail latency.
For uniform, short, stateless requests with sticky-session needs, round robin (or consistent hashing) is cheaper and avoids the cross-LB connection-count bookkeeping. Least-connections also gets tricky across multiple LB instances.
Thirty product teams each maintain their own continuous integration (CI) pipelines, Terraform modules, and Kubernetes manifests, and you have budget for six platform engineers. A base image upgrade takes a full quarter because every team does it separately.
A central team should own one shared set of pipelines, modules, and base images, because the waste here is thirty copies of the same work. Spread across thirty teams, six engineers become part-time helpers who each patch a local fork, and the duplication stays. A central team ships the upgrade once and turns adoption into a version bump.
When teams' infrastructure needs genuinely diverge, such as a machine learning team running GPU fleets beside web teams, embedding specialists where the unique work lives pays off more than a shared layer.
You're moving a billing system that processes $2M a day off a 12-year-old monolith onto new services. It exposes 40 endpoints, and finance allows at most one hour of billing downtime per year.
Route traffic through a facade and move one endpoint at a time, so each piece proves itself under real load and can roll back on its own. A big-bang cutover puts all 40 endpoints and every undocumented edge case into one weekend, and a single miss spends the downtime budget for the year. The strangler path also delivers value from the first endpoint moved.
When the old and new systems cannot run side by side, such as a vendor contract that ends on a fixed date or a data model that can't be dual-written, a rehearsed big-bang cutover is the safer plan.
Sixty engineers ship a web app weekly from one repository. A pricing redesign will take three teams six weeks, and customers can't see it until finance signs off.
Merge the redesign to trunk daily behind a flag, so it integrates continuously and launches with a config change once finance approves. A six-week branch shared by three teams drifts from a trunk that sixty people are changing, and the final merge becomes its own project with its own bugs. Flags also allow a staged rollout and an instant off switch.
When customers install your software and you patch old versions for years, such as on-prem releases or firmware, long-lived release branches are the standard way to maintain each supported version.
Your 200 engineers need on-call scheduling, paging, and incident timelines. Building it would take three engineers two quarters, and a vendor quotes $60,000 a year.
Paging is undifferentiated work that vendors have hardened across thousands of customers, so the license is the cheaper and safer choice. Three engineers for two quarters costs several times the annual fee before counting years of maintenance, and a homegrown pager that fails during an outage is the worst failure it could have. Those engineers are worth more on the product customers pay for.
When the tool is core to your differentiation or no vendor meets a hard constraint, such as air-gapped deployment or data residency, building is worth the ongoing cost.
A revenue-critical Rails app has 400,000 lines, 30% test coverage, and twelve engineers. Leadership expects new features every sprint while the code is cleaned up.
Refactor in place, adding tests around each area before changing it, so the product keeps shipping and every improvement reaches production. A rewrite has to rediscover years of edge cases from code with little test coverage, and it freezes features for a year or more while the old system keeps changing underneath it. Rewrites of this size routinely run long and launch with regressions.
When the platform itself is the problem, such as an unsupported runtime or a framework that can't meet a new requirement, a scoped rewrite of the affected part beats patching a dead end.
You're moving 40 teams to a new observability stack that cuts logging costs in half. The old stack still works, and every team is mid-roadmap.
Make the new stack the default in service templates and do the migration work for teams, so adopting it is easier than staying put. Mandates on working systems produce resentment, exceptions, and half-finished migrations while teams protect their roadmaps. Track adoption, and save a hard deadline for the last few holdouts once most teams have moved.
When a straggler means a security or compliance failure, such as an audit deadline or a vulnerable library, a mandate with a fixed date is the right call.
A central site reliability engineering (SRE) team of eight takes every page for 150 services owned by 25 teams. Mean time to recovery (MTTR) has doubled in a year, and most incidents trace back to recent deploys.
Move first-line on-call to the owning teams, because the people who shipped the change are the fastest to diagnose and fix it. Eight SREs cannot hold context on 150 services, and teams that never get paged have little reason to invest in reliability. Keep the SRE team as the source of tooling, standards, and support on the largest incidents.
When a small set of systems needs deep operational expertise, such as shared databases or the core network, a dedicated SRE team owning them outright works better.
Checkout calls nine downstream domains synchronously, including fulfillment, loyalty, fraud, and analytics, and new consumers arrive every quarter. An outage in any one of them currently fails checkout.
Publish an OrderPlaced event as the contract and let each domain consume it at its own pace. Checkout's availability then stops depending on nine other teams, and a new consumer subscribes without any change to checkout. Version the event schema with the same care as a public API, because every consumer depends on it.
When checkout needs an answer before it can proceed, such as a fraud decision or an inventory reservation, keep that call synchronous and use events for everything downstream.
A 2,000-engineer company has 40 product domains and a central data team of 25 with a nine-month backlog of pipeline requests. Most domains already have engineers maintaining unofficial pipelines of their own.
Give domains ownership of their data products and turn the central team into the platform that makes publishing them easy. Twenty-five people cannot learn 40 domains well enough to model their data, which is why the backlog keeps growing and shadow pipelines keep appearing. The central team still sets shared standards for schemas, quality checks, and access control.
When the company has a handful of domains, or few engineers with data skills outside one team, a central data platform is faster and more consistent.
Three hundred backend engineers work across five languages. A platform team of ten maintains build tooling and software development kits (SDKs) for all five, and on-call engineers often can't read the services they get paged for.
Standardize new services on one primary language and approve exceptions case by case, so the shared tooling bill stops multiplying by five. Every extra language needs its own build tooling, SDKs, security patching, and experts, and that cost lands on the platform team and on-call. One language also lets engineers move between teams and read any service during an incident.
When a domain has needs the standard language serves poorly, such as data science in Python or low-latency systems in Rust or C++, approve that language for that domain.
After a regional outage at your only cloud provider, the board asks for multi-cloud. You have 400 engineers, deep use of managed services, and no regulatory requirement for a second provider.
Spread across multiple regions of the provider you already use, which addresses the outage that actually happened at a fraction of the cost. Multi-cloud means trading managed services for lowest-common-denominator tooling or building everything twice, and it doubles the expertise every team needs. Regional failures are far more common than a whole provider going down.
When a regulator requires an exit plan, or large customers require you to run in their cloud, a second provider becomes a business requirement worth the cost.
Your event streaming moves 3 GB per second, the managed service bill is $4M a year and growing 60% annually, and you already employ six engineers who run streaming infrastructure.
Run the open source system yourself, because at this volume the vendor's margin exceeds the cost of the team you already have. Six experienced engineers can operate large clusters, and owning the stack removes the pricing leverage the vendor holds at every renewal. Plan it as a staged migration with dual writes, and budget for the on-call load it adds.
When usage is modest or no one on staff has run the system at scale, the managed service is cheaper once you count salaries, on-call, and the outages a new team will cause.