Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

codewithmukesh
Back to blog
dotnet architecture 58 min read Lesson 145/152 New

30 .NET System Design Interview Questions That Actually Get Asked in 2026

30 scenario-based .NET system design interview questions with senior answers, red flags, and follow-ups. Caching, background jobs, real-time, scale on .NET 10.

30 scenario-based .NET system design interview questions with senior answers, red flags, and follow-ups. Caching, background jobs, real-time, scale on .NET 10.

dotnet architecture

interview-questions system-design dotnet-system-design dotnet-10 csharp interview-prep dotnet-interview system-design-interview senior-dotnet hybridcache caching background-jobs hangfire signalr rate-limiting idempotency multi-tenancy scalability aspnet-core ef-core

Mukesh Murugan
Mukesh Murugan
Solutions Architect · Microsoft MVP
Chapter 145 of 152
View course

.NET Web API Zero to Hero Course

From dotnet new to docker push - REST, EF Core 10, auth, caching, Clean Architecture, observability. 152 hands-on lessons, source on GitHub.

In a .NET system design interview in 2026, you will mostly get scenario questions about systems you might actually build at work, like a multi-tenant rate limiter, an export that takes four minutes, or a notification feed for 100,000 users. What the interviewer is really checking is whether you can pick a sensible default, and then explain the point where that default stops working.

There is surprisingly little good preparation material for this round, even though it is usually the round that decides senior offers. If you search for system design interview questions, you mostly get walkthroughs of Instagram, TikTok, and Google Maps. Those are useful if you are interviewing at Meta, but they don’t help much when the interviewer asks how you would stop a nightly import from taking your API down.

So I put together the .NET version. This article has 30 system design questions, each written the way the interview actually asks them. For every question, you get a realistic scenario, how I would answer it, an answer that usually gets a candidate rejected, and the follow-up question you should expect next. Everything is current for .NET 10. For example, the caching answers use HybridCache and explain its limits, the background job answers compare BackgroundService with Hangfire and Quartz.NET, and the real-time answers cover what breaks when you scale SignalR out to a third instance. Let’s get into it.

What Does a .NET System Design Interview Actually Test?

Mostly, it tests whether you can make a decision and explain it.

Almost every candidate can list the options: “You could use in-memory caching, or distributed caching, or a CDN.” But that answer scores zero, because it doesn’t actually decide anything. A senior answer picks a default, explains why, and then mentions the condition that would make you change your mind. Here are the four things interviewers listen for:

  1. A named default. For example, “I’d start with HybridCache.” One concrete choice is better than a long list of options.
  2. The mechanism. Why that default works, in one or two sentences. This is where it becomes obvious if someone has only read about a tool and never used it.
  3. The boundary. Where the default stops working. If you know the exceptions, it shows that you really understand the rule.
  4. The failure path. What happens when the dependency you just added goes down. Very few candidates bring this up on their own, and doing so is one of the quickest ways to come across as senior.

None of the questions below has a single right answer. What matters is whether your answer shows that you have actually run a system in production, or only drawn one on a whiteboard.

This page is part of my .NET interview prep series and covers the system design round. For the layers underneath it, see the .NET interview questions hub, the senior .NET developer interview questions for runtime and production debugging, and the .NET microservices interview questions for everything that happens once the system is split across services.

Practice

Test your system design reasoning for free

Design answers usually fall apart during the follow-up questions. Take a free, auto-scored mock interview at Junior, Mid, or Senior level and get an instant score with a per-topic breakdown. You don't need to sign up to start.


Read Scale and Caching

Most .NET system design rounds start here. Almost every real system reads a lot more than it writes, and caching is usually the first thing people reach for. That is also why this section gets the weakest answers. Saying “I would add Redis” is not a design.

Q1. Design the Read Path for a Product Catalog That Takes 50 Times More Reads Than Writes.

Mid

I’d start by asking how stale the data is allowed to be, because that one number decides most of the design. A catalog that can tolerate 60 seconds of staleness is a caching problem. A catalog that must be correct to the millisecond is a database problem, and a much more expensive one.

Assuming a normal catalog that can be a few seconds stale, my default read path has three layers. The first layer is an in-process cache, because a memory hit takes nanoseconds and needs no network call. The second is a shared distributed cache, so that a freshly started instance doesn’t go straight to the database, and all instances see the same data. The database comes last, behind a single-flight guard, so that one cache miss doesn’t turn into hundreds of identical queries.

In .NET 10, you don’t have to build this yourself. HybridCache gives you exactly this shape: L1 in-process backed by MemoryCache, L2 backed by whatever IDistributedCache you register, and per-key request collapsing built in. The write path then has only one job, which is to invalidate the cache when the data changes. I would tag catalog entries, so that a category update can invalidate a whole set with RemoveByTagAsync instead of guessing at keys.

There is one detail you should know before you say this in an interview. Tag invalidation in HybridCache is logical, which means RemoveByTagAsync doesn’t actually delete anything. It records an “ignore anything created before this point” marker for that tag, and entries older than the marker are then treated as misses on the next read while the stale bytes sit in MemoryCache and Redis until they expire normally. Reads still behave correctly, but invalidation doesn’t free up any memory, so don’t count on it for that.

Red flag answer: “I’d cache everything with a 24-hour expiry.” - This answer ignores what happens when a price changes. If your answer has no plan for invalidation, it has a data-correctness bug in it.

Follow-up: “A product’s price changes. Walk me through every place that stale value could still be served from.”

Q2. Your Dashboard Endpoint Makes 14 Database Round Trips. Redesign It.

Mid

First I would measure before redesigning, because 14 round trips against a database on the same network might be 12ms total and not the problem at all. If it’s the problem, the fix depends on why there are 14.

If they’re 14 different questions, the answer is usually one materialized read model instead of 14 live queries. A dashboard is a read-only projection, so I’d build it as one: a table or cached document shaped exactly like the screen, which gets updated when the underlying data changes. That turns 14 queries into one lookup, and this is one of the cases where CQRS is actually worth its extra complexity.

If they’re the same query repeated with different ids, it’s an N+1 problem, and the fix is a single batched query. Caching an N+1 only hides it until the cache is cold, and then the cold start takes the database down. In an interview, I would say this diagnosis out loud, because answering “cache it” to an N+1 problem is a classic trap at the senior level.

Red flag answer: “Add Redis in front of it.” - Caching a slow query doesn’t make the query any faster. It only makes every cache miss much more painful.

Follow-up: “The read model is now one query. What updates it, and what happens if that updater is down for an hour?”

Q3. Pick Between IMemoryCache, HybridCache, and Redis for This System, and Defend It.

Senior

This question asks for a decision, so your answer should be a clear decision. For a new .NET 10 service, mine is: use HybridCache by default. It gives you the in-process speed of IMemoryCache and the cross-instance agreement of a distributed cache behind one API. It also handles cache stampedes out of the box, which is exactly the part people usually get wrong when they build it themselves. It ships in Microsoft.Extensions.Caching.Hybrid and has been generally available since .NET 9. One thing that might surprise you is that the API surface still carries [Experimental] on .NET 10, so you have to suppress EXTEXP0018 to build. The library is production-ready. The attribute only means that signatures and option names can still change in a minor release.

IMemoryCacheHybridCacheIDistributedCache with Redis
Where it livesIn-process, per instanceL1 in-process, L2 sharedOut of process, shared
Read costNanosecondsNanoseconds on L1 hitOne network round trip
Instances agreeNoYes, through L2Yes
Survives restartNoOnly the L2 halfYes
Stampede protectionNone, you hand-roll itBuilt in, per instanceNone
Serialization costNoneOn L2 reads and writesOn every read and write
VerdictSmall, cheap, per-instance dataDefault for new servicesWhen you need only the shared tier, or something else already reads it

Here is why I pick it as the default. A single-tier Redis cache pays for a network hop and a deserialization on every single read, but most cached values are read far more often than they change. A two-tier cache turns that common case into a simple memory read. The cost is that each instance now holds its own copy of L1, so invalidation has to reach every instance, and memory usage multiplies by instance count.

I would pick plain IMemoryCache when the data is genuinely per-instance and cheap to rebuild, and I’d go straight to IDistributedCache when something outside the app reads the same cache.

Red flag answer: “Redis, because it’s distributed and distributed is better.” - A distributed cache comes with a cost, and you should only pay it when you actually need what it gives you. If every read now costs a network hop for data that never changes, you have made the system slower and added a dependency that can take it down.

Follow-up: “You picked HybridCache. Three instances, one key expires at the same moment on all three. How many database calls happen?”

Q4. A Hot Key Expires and the Database Falls Over. Design Around It.

Senior

This is called a cache stampede, and it also answers the follow-up from the previous question. The moment a popular key expires, every in-flight request misses at once and they all run the same expensive query. The database gets N identical queries where it expected one, and the recovery makes it worse because each retry adds another.

The fix is single-flight. For a given key, one caller executes the factory and every other caller waits on that result. HybridCache does this for you, which is the strongest single reason to prefer it over hand-rolled IMemoryCache code.

Here is the detail that makes an answer strong, and it is documented behavior: HybridCache stampede protection is per instance, not cluster-wide. The docs put it plainly - the coordination “doesn’t extend to other HybridCache instances, even if they use the same secondary distributed cache” (Microsoft Learn, and dotnet/extensions#6759 for the discussion). Each machine collapses its own concurrent callers, but three instances that miss simultaneously still produce three database calls, not one. So on a 20-instance deployment, a hot key expiry costs 20 queries rather than 20,000. That is usually fine, and being able to explain why it’s fine is what the interviewer wants to hear.

If 20 is still too many, the next moves are staggered expiry so keys don’t all die on the same second, and refresh-ahead so a background refresh repopulates the key before it expires and reads never miss at all.

Red flag answer: “I would set a longer expiry.” - This only delays the stampede, and you get staler data in return. The real problem is that all the requests miss at the same moment, and a longer expiry doesn’t change that.

Follow-up: “Refresh-ahead means something has to know the key is about to expire. What runs that, and what happens when the refresh itself fails?”

Q5. How Do You Cache Something That Must Be Correct Within Five Seconds?

Senior

Five seconds is tight, but it’s workable. I would avoid the obvious answer, which is a five-second expiry. Short expiries get you correctness by throwing away your cache hits and hammering the source, which defeats the whole reason for having a cache.

I would invalidate on write instead of expiring on a timer. The writer knows the exact moment the value changed, so the writer should evict. That gives correctness measured in milliseconds rather than seconds, and lets me keep a long expiry as a safety net for the case where an invalidation message is lost.

The local cache is the easy part. The hard part is the other instances, and this is documented behavior: when HybridCache invalidates by key or tag, the entry is invalidated on the current server and in the shared L2, but “the in-memory cache in other servers isn’t affected” (Microsoft Learn). So the writer evicts its own L1 and Redis, and every other instance keeps serving its stale copy until that copy expires. The design needs a broadcast: a Redis pub/sub channel or the messaging infrastructure already in the system, publishing “key X changed” so every instance drops its local copy. At that point, the five-second budget depends on broadcast latency, which is typically single-digit milliseconds and comfortably inside budget.

I’d also say out loud what the failure mode is: if the broadcast is lost, that instance serves stale data until the backstop expiry fires. That’s why the backstop still exists.

Red flag answer: “Set the TTL to five seconds and you’re done.” - This technically meets the requirement, but it destroys your hit rate. On a hot key this can be slower than no cache at all, because now you pay both the cache miss and the cache write on almost every request.

Follow-up: “The broadcast is lost for one instance. How would you even find out that happened in production?”

Read next

HybridCache in ASP.NET Core (.NET 10)

The full implementation of the two-tier cache these answers keep pointing at, including tagging and invalidation.


Write Scale and Data

Scaling reads is a fairly well understood problem, and the tools are well known. Scaling writes is where designs start to differ, and it’s where interviewers find out whether you have actually run a system under load.

Q6. Design an Ingest Endpoint That Must Accept 10,000 Writes Per Second.

Senior

My first question back would be what “accept” means, because the whole design depends on it. If the client needs confirmation that the data is durably stored and queryable, I have a 10,000 writes per second database problem. If the client only needs confirmation that the data was received, I have a queueing problem. These two cost very different amounts to build and run.

In almost every case, it’s the second one. So the design is to validate the payload, write it to a durable buffer, and return 202 Accepted with a location the client can poll. The endpoint only has to do two things: be fast, and not lose data. A separate consumer drains the buffer and writes to the database in batches, because 10,000 individual inserts per second is a very different load than 100 batches of 100.

For the buffer, my default is a real broker rather than an in-process queue. System.Threading.Channels is excellent for in-process producer and consumer handoff and I use it often, but it lives in memory, so a pod restart drops everything still in flight. For an ingest endpoint that promised durability the moment it returned 202, that is a data-loss bug.

On the write side, I would batch the work and use bulk insert instead of row-by-row inserts, because per-row inserts through a change tracker won’t reach 10,000 per second on any hardware you want to pay for.

Red flag answer: “I’d scale out the API to 20 instances.” - Twenty instances all writing to one database just moves the bottleneck to the database, and makes contention worse. Scaling a tier that wasn’t the bottleneck in the first place is the most common wrong answer in this round.

Follow-up: “You returned 202. The consumer then fails permanently on that message. How does the client ever find out?”

Q7. Two Hundred Concurrent Requests Contend on the Same Row. Design the Write Path.

Mid

First I would ask what the row is, because the answer changes a lot depending on that. A counter needs a different design from a business entity, and treating them the same way is how people end up with a lock that nobody can remove later.

For a counter, don’t read-modify-write in application code. That pattern always has a race condition, and adding a lock around it forces all 200 requests through one critical section, one at a time. I’d push the increment into the database as a single atomic statement so the database’s own row lock does the work in microseconds, or move the counter out to something built for it.

For a business entity where the update depends on current state, optimistic concurrency is my default. Every row carries a version, the update checks that the version is still the one it read, and a mismatch means somebody else updated the row first. The caller then retries against fresh data or surfaces a conflict to the user. I default to optimistic locking over pessimistic locking because conflicts on most rows are rare, and pessimistic locking makes every request pay for a problem that only affects a few of them.

Here is where it stops working. When contention is high and retries keep failing, optimistic concurrency turns into a retry storm. At that point, the real answer is usually to remove the contention itself, by queueing the updates for that entity so they run one after another on purpose.

Red flag answer: “Use a lock statement around the update.” - A lock is per process, so it does nothing across instances, and it holds a thread while doing I/O. It looks safe, but it doesn’t protect you at all.

Follow-up: “You said retry on conflict. What’s your retry limit, and what does the user see when you hit it?”

Q8. A 2 GB CSV Imports Every Night and Must Not Take the API Down. Design It.

Senior

There are three separate problems in this question: memory, database load, and failure recovery. I would handle them in that order, because that is the order in which they usually cause trouble.

Let’s start with memory. A 2 GB file must never be fully loaded into memory. I’d stream it, reading and parsing row by row and yielding batches, so process memory stays flat regardless of file size. Loading it into a List is how a 2 GB file becomes an 8 GB process and a large object heap problem.

Next is database load, and this is the part that actually puts the API at risk. A single import transaction inserting millions of rows will hold locks long enough to block live traffic, which is exactly the outage the question is about. I would insert in bounded batches with a pause between them so live queries get scheduled in between, and accept that the import takes longer in exchange for not causing an incident. If the target table is heavily read, I’d load into a staging table and swap.

The last one is failure recovery. The import must be resumable, because a 40-minute job that fails at minute 38 and has to start over will eventually run into the morning. So the job tracks its progress, and it is idempotent per row, keyed on a natural value from the file, so that a rerun corrects the data instead of duplicating it.

Red flag answer: “Wrap the whole import in one transaction so it’s all-or-nothing.” - Atomicity sounds like the responsible choice, but here it’s exactly what takes production down, because the locks are held for the whole import.

Follow-up: “Halfway through, you find row 400,000 is invalid. Do you fail the import or skip the row? Defend either.”

Q9. When Does EF Core Stop Being the Right Tool, and What Replaces It?

Senior

Rarely, and much later than most people think. My default is EF Core for effectively all application data access in .NET 10, because change tracking, migrations, and LINQ pay for themselves everywhere and the performance gap on ordinary queries is small enough that it’s almost never the reason an endpoint is slow.

I reach for raw SQL or Dapper in three specific places. The first is bulk operations, where the change tracker is pure overhead and a set-based statement is the right tool. The second is complex reporting queries, where hand-written SQL is clearer than the LINQ that would generate it, and where I want exact control over the query plan. The third is measured hot paths, where profiling has actually shown that materialization cost matters, and that list is usually much shorter than people expect.

The point I would make in the interview is that you don’t have to pick one or the other. EF Core can run raw SQL just fine, so the escape hatch lives inside the same DbContext and the same transaction. Choosing “Dapper for the whole application” because one query was slow is throwing away migrations and change tracking to fix something that a single FromSql call would have fixed.

The real boundary is when the data stops being relational. At that point, the problem is your choice of database rather than EF Core, and swapping the ORM won’t fix it.

Red flag answer: “EF Core is slow, so I use Dapper for everything.” - This is almost always a wrong diagnosis. Slow endpoints are usually N+1 queries, missing indexes, or tracking queries that should have been no-tracking, and every one of those survives the rewrite to Dapper.

Follow-up: “You moved one query to raw SQL. What did you just lose, and how do you stop it drifting from the model?”

Q10. Design an Audit Trail That Cannot Be Switched Off and Cannot Slow Writes Down.

Senior

These two requirements pull against each other, so the design comes down to where I draw the line between them.

“Cannot be switched off” means it must not depend on developers remembering to do it. If every call site has to call WriteAudit(), one of them will be missing it within a month. So I’d capture it at the data layer: an EF Core interceptor or an override of SaveChangesAsync that reads the change tracker and records what changed, who changed it, and when. The change tracker already knows the before and after values, so the entire audit payload is already there for you.

“Cannot slow writes down” is the tension, because writing audit rows inside the same transaction doubles the write cost. My default is to accept that cost, because an audit trail that can disagree with the data it audits is worth very little, and a same-transaction write is the only way to guarantee they agree. For most systems, the extra insert isn’t the bottleneck, and the mistake is claiming that it is without measuring it.

When the volume really makes that impossible, I would write the audit record to an outbox table in the same transaction and have a background relay move it to cheaper storage. This keeps the write atomic and moves the expensive part off the request path. The trade-off is that the audit store is eventually consistent, a few seconds behind.

Red flag answer: “Log it with the logging framework and ship the logs somewhere.” - Logs are lossy by design, sampled under load, and rotated on a retention policy. Logs are a diagnostic tool, and they are not an audit trail. You will feel the difference the first time somebody asks the system a legal question.

Follow-up: “Your audit says a user changed a field. The user says they did not. What in your design lets you tell who’s right?”


Background and Async Work

Every .NET system ends up with some background work, and interviewers ask about it more every year. This is also the category where the .NET-specific answer differs the most from the generic system design answer, so it tells the interviewer a lot about you.

Q11. Design a Background Job System in .NET. BackgroundService, Hangfire, Quartz, or a Broker?

Senior

This is another decision question, so again, I’ll give a decision. My default for a job that must survive a restart is Hangfire, and my default for continuous in-process work is BackgroundService. The two are built for different kinds of work, so they don’t really compete.

BackgroundServiceHangfireQuartz.NETBroker with consumers
Survives restartNo, in-memory onlyYes, persisted to storageYes, persisted to storageYes, persisted in the broker
RetriesYou write themBuilt in, with backoffYou write themBroker redelivery
SchedulingTimer you writeCron and delayed jobsFull cron, calendars, misfiresNeeds a scheduler on top
Runs across instancesEvery instance runs itOne worker picks each jobClustered mode picks oneOne consumer per message
DashboardNoneBuilt inNoneBroker tooling
Extra infrastructureNoneA databaseA databaseA broker
Fits bestContinuous in-process workFire-and-forget and scheduled jobsComplex scheduling rulesCross-service work and high volume

Here is my reasoning. BackgroundService is the right fit for something that runs for the lifetime of the process, like draining an in-memory channel or maintaining a connection. It’s the wrong fit for “send this email”, because the work only exists in that process’s memory, so a deployment during the job loses it silently.

The trap in this question is the “runs across instances” part. A naive BackgroundService on a three-instance deployment runs the job three times, because every instance runs its own copy. Candidates who have only used it on a single machine miss this every time, and it’s the follow-up I’d expect.

Red flag answer: “I’d use BackgroundService with a timer for everything.” - This works on one instance in development. In production on three instances, it does the work three times and loses in-flight jobs on every deploy.

Follow-up: “You picked Hangfire. Now a job takes 40 minutes and a deploy happens at minute 30. What happens to it?”

Q12. A User Clicks Export and It Takes Four Minutes. Design the Flow.

Mid

Nothing that takes four minutes should hold an HTTP request. Load balancers, reverse proxies, and browsers all have timeouts shorter than that, so the synchronous version fails in production even when it works locally.

So the export has to become a background job that the client can track. The POST validates the request, enqueues the work, and immediately returns 202 Accepted with a job id and a status URL. The client polls that URL, or subscribes for a push if the system already has that channel. When the job finishes, it writes the file to blob storage and the status endpoint returns a time-limited download link rather than streaming the bytes through the API.

There are two details I would bring up on my own, because they are what make this work with real users. First, deduplicate on the request: if the same user clicks export three times, three identical four-minute jobs shouldn’t run. An idempotency key derived from the user and the export parameters collapses them into one. Second, the job needs a terminal failure state and the status endpoint has to report it, because if a job silently disappears, the user is left staring at a spinner forever.

Red flag answer: “Increase the request timeout.” - This just moves the failure from the proxy to the browser. It also holds a thread and a connection for four minutes, and the work is guaranteed to be lost if the pod restarts.

Follow-up: “Two users request the same export at the same second. Walk me through what your idempotency key does.”

Q13. Design Retry and Failure Handling for a Job That Calls a Flaky Third Party.

Senior

Retries are easy to get almost right, and just as easy to get dangerously wrong. So I would walk through four decisions explicitly.

What to retry. Only transient failures. Timeouts, connection failures, 429, and 5xx are retryable. A 400 will fail identically forever, and retrying it burns quota and delays the real error. Distinguishing these is the first thing I would look for as an interviewer.

How to retry. Use exponential backoff with jitter, and a hard cap on attempts. The jitter is important. Without it, every failed call retries on the same schedule, so the retries arrive in synchronized waves and hit the recovering service hardest at the exact moment it’s trying to come back.

When to stop retrying entirely. Use a circuit breaker, so that a dependency that’s fully down stops receiving traffic instead of collecting a queue of doomed calls.

Where failures go. Send them to a dead letter store, with enough context to replay them. If a job fails permanently and just vanishes, you have lost data, and nobody will notice for a week.

In .NET 10 this is Microsoft.Extensions.Http.Resilience, which gives a standard handler with timeout, retry, and circuit breaker already composed. There is one licensing detail here that surprises people: that package pulls Polly as a transitive dependency, and Polly joined the Open Source Maintenance Fee with fees starting 2026-11-16 for organizations above a revenue threshold. The announcement doesn’t spell out how transitive use is treated, so read it yourself instead of assuming either way.

Red flag answer: “Retry three times and move on.” - There is no backoff, no jitter, and no check on which errors are worth retrying. This is the answer that turns a partial outage into a full one, because the retries themselves become the load.

Follow-up: “Your retries are idempotent, right? Prove it. The third party charged a card on attempt one and timed out before responding.”

Q14. Your BackgroundService Died Silently in Production. Design So That Cannot Happen.

Senior

This is one of my favorite questions to ask, because most candidates answer it with behavior that changed five .NET versions ago.

The old answer was “an unhandled exception escaped ExecuteAsync, the task faulted, and the host carried on serving HTTP with the background work quietly dead.” That was true before .NET 6, but it isn’t true anymore. Since .NET 6 the default BackgroundServiceExceptionBehavior is StopHost, so an unhandled exception is logged and then stops the host. On .NET 10 that failure is loud: the process exits, the pod restarts, and liveness notices.

So the interesting question is what can still fail silently, and there are two ways it can happen. The first is ExecuteAsync returning normally. The host never awaits it, so a method that completes without throwing is simply finished, and nothing anywhere treats that as an error. A while loop that exits, a catch that breaks instead of continuing, an awaited subscription that completes: the work stops, the host keeps serving, and no exception is ever raised to stop anything. The second is a loop that catches and continues correctly but fails on every iteration, which by design produces no unhandled exception at all.

Neither of these shows up if you only watch the process, so the design has to watch the work itself. Treat ExecuteAsync returning as an alertable event, because in a service meant to run until shutdown, completion is abnormal. Track the timestamp of the last successful iteration rather than the last attempt. Then expose that: a health check reporting “last success was 90 minutes ago, interval is 5 minutes” catches both cases on the first miss. A check that only proves the object exists catches neither.

This applies beyond background services too. If your signals only prove that the process is alive, silent failures will stay silent.

Red flag answer: “An unhandled exception kills the service and the host keeps running.” - This is the pre-.NET 6 behavior, and a good interviewer will know it. Setting BackgroundServiceExceptionBehavior.Ignore is the only way to get that back on .NET 10, and doing so deliberately re-creates the failure mode.

Follow-up: “Your job now catches and continues. It has been throwing on every iteration for two days. What tells you?”

Q15. Design Scheduled Work That Must Run Exactly Once Across Six Instances.

Mid

If six instances each run their own scheduler, the job runs six times. So something has to decide which instance runs it, and that decision has to live outside the processes, because nothing inside one process can see the other five.

The usual approach is a lease. Before running, an instance atomically claims a lock keyed on the job and the scheduled slot, with an expiry. Exactly one claim succeeds, that instance runs, and the others skip. The expiry is important. If the winning instance dies in the middle of the job, the lease has to expire so the work isn’t blocked forever.

In practice, I wouldn’t build this myself. Hangfire and Quartz.NET in clustered mode both solve it, and hand-rolled distributed locks are well known for being easy to get subtly wrong. Naming an existing library is a better answer than designing one on a whiteboard.

Then there is the part I’d want to hear from a senior candidate: exactly-once does not survive a crash. If the instance dies after doing the work but before recording that it did, the lease expires and another instance repeats it. What the lease actually gives you is “usually once,” and the only real protection is making the job idempotent so a repeat is harmless. Candidates who say this without being asked show that they have actually run distributed schedulers in production, and not just configured one.

Red flag answer: “Run the scheduler on only one instance.” - Now that instance is a single point of failure with no failover. On top of that, a business requirement is hidden in the deployment setup, where nobody will think to look for it.

Follow-up: “The lease is 5 minutes. The job usually takes 30 seconds but today it takes 6. What goes wrong?”


Real-Time and Push

This is a small category, but a lot of candidates struggle with it. Real-time questions show whether you have ever deployed a stateful protocol behind a load balancer, and that kind of experience doesn’t come from tutorials.

Q16. Design a Live Notification Feed for 100,000 Concurrent Users.

Senior

The first decision is the transport. That choice should depend on which direction the traffic flows, and not on which technology sounds the most interesting.

PollingServer-Sent EventsSignalR / WebSockets
DirectionClient pullsServer to client onlyBoth directions
Connection costNone held openOne held per clientOne held per client
LatencyHalf the poll intervalPush, immediatePush, immediate
Works through old proxiesAlwaysUsuallySometimes, needs fallbacks
Reconnect handlingFreeBuilt into the protocolHandled by the client library
Scale-out needNoneBackplane or sticky routingBackplane and usually sticky sessions
Fits bestLow frequency, high toleranceServer-push feeds like notificationsChat, collaboration, live cursors

For a notification feed, traffic is one-directional, so my default is Server-Sent Events. It’s a plain HTTP response that stays open, it reconnects on its own, and I don’t have to run and maintain a bidirectional protocol that I don’t need. I would move to SignalR when the client also needs to send, or when I need its group and hub abstractions and connection management enough to pay for the backplane.

At 100,000 concurrent connections, CPU is no longer the limit. What runs out first is memory and file descriptors per connection, and that number decides how many servers you need. Say this in the interview, because it shows that your estimate is based on what actually runs out.

I’d also make the feed durable, instead of purely live. If a notification only exists in a push, anyone who was disconnected at that moment never sees it. So notifications are saved first, and the connection delivers them, which also means a reconnecting client can ask for what it missed.

Red flag answer: “SignalR, because it’s the .NET real-time framework.” - The product is right, but there is no reasoning behind it. This answer falls apart at the follow-up about scaling it out.

Follow-up: “A user has your app open in three tabs on two devices. What does a single notification do?”

Q17. SignalR Works on One Instance and Breaks on Three. Design the Fix.

Senior

There are two different failures behind this one symptom, and a strong answer separates them.

The first one is message routing. A SignalR connection is stateful, and it lives on exactly one server. When instance A calls Clients.All.SendAsync, only clients connected to instance A receive it. Clients on B and C get nothing. The fix is a backplane, so that a message published on any instance reaches clients on all of them. The Redis backplane is the self-hosted option, and Azure SignalR Service is the managed one.

The second one is setting up the connection, and this is the part people usually miss. With the Redis backplane, the server environment must be configured for sticky sessions, because the negotiate request and the connection that follows it have to land on the same server. Without sticky sessions, connections fail or fall back in ways that look random, and they are really painful to debug. Anything other than WebSockets, such as Server-Sent Events or long polling, makes this mandatory.

There is one difference worth pointing out here: Azure SignalR Service does not need sticky sessions, because clients are redirected to the service itself on connect, so the app servers don’t hold connections at all. That is why I default to it on Azure, and to the Redis backplane when self-hosting. Also, Microsoft’s guidance is that a Redis backplane should run in the same data center as the app, since the latency otherwise shows up in every message.

Red flag answer: “Add the Redis backplane and it’s fixed.” - This solves routing, but leaves the connection setup broken. A half fix that causes random failures is worse than a problem that fails in an obvious way.

Follow-up: “The backplane is down. What do connected clients experience, and what do new clients experience?”

Q18. Design Presence, Meaning “Who Is Online”, Without Hammering the Database.

Mid

Presence data gets written a lot, isn’t very valuable, and can be slightly wrong without hurting anyone. That is an unusual combination, and it should decide where you store it. Writing every heartbeat to the primary database is the wrong choice, because it attaches the highest write volume in the system to its least important data.

So presence goes in a cache with an expiry, and stays out of the database. Each client heartbeat refreshes a key with a TTL a little longer than the heartbeat interval. If the key exists, the user is online, and if it has expired, the user is offline. There is no explicit “go offline” write and no cleanup job, because the expiry does the cleanup. It also handles the case that always breaks explicit tracking, which is a client that disappears without disconnecting cleanly.

The scaling question is about how presence is read. “Is this one user online” is a single key lookup, so it’s cheap. “Show me all 400 of my contacts’ statuses” is 400 lookups and needs batching. And “how many users are online right now” should be a counter that you keep updated, instead of a scan, because scanning keys to count them is exactly the kind of operation that takes a cache down.

I would also state the tolerance clearly: presence is correct to within one heartbeat interval. That is a deliberate trade-off, and it’s not a bug.

Red flag answer: “A LastSeen column updated on every heartbeat.” - 100,000 users on a 30-second heartbeat is over 3,000 writes per second to the primary database, for data that nobody would pay to make durable.

Follow-up: “Your cache restarts and loses everything. What does the application show, and how long until it’s right again?”


API Surface, Tenancy, and Files

This category overlaps the most with the API work that .NET developers do every day, so the bar is higher. A vague answer here looks much worse than a vague answer about distributed consensus.

Q19. Design a Multi-Tenant API. Where Does the Tenant Boundary Actually Live?

Senior

There are three possible boundaries, and the whole question is about picking one: separate database per tenant, shared database with a separate schema per tenant, or shared everything with a tenant column on each row.

My default is shared database with a tenant id column, because it’s by far the cheapest to operate and it scales to a large number of tenants without migrations becoming a full-time job. Database-per-tenant gives the strongest isolation and the simplest “delete this customer’s data” story, and it’s the right answer for a small number of large enterprise tenants, or when a contract or regulation requires physical separation. Schema-per-tenant is usually the worst of both options, because you get most of the migration pain without most of the isolation benefit.

The part that matters even more than the choice is this: with a shared database, filtering must not be the developer’s responsibility. One forgotten WHERE TenantId = ... is a cross-tenant data leak, and that is the kind of bug that can end a company. So the filter belongs in one place that can’t be bypassed, which in EF Core means a global query filter applied to every tenant-scoped entity, with the tenant resolved once per request.

There is a .NET 10 detail worth mentioning here, because it closes the most common hole in this design. Before EF Core 10 an entity could carry only one query filter, so tenancy and soft-delete had to be combined into a single expression, and IgnoreQueryFilters() was all-or-nothing: an admin or reporting query that wanted to see soft-deleted rows switched off tenant isolation at the same time. EF Core 10’s named query filters let you register several per entity and disable them individually, so IgnoreQueryFilters(["SoftDelete"]) lifts exactly one and leaves the tenant filter standing.

There are two more issues I would bring up without being asked. The first is the noisy neighbor problem, where one tenant’s load slows down everyone else, and that leads straight into the next question. The second is background jobs. They run without an HTTP request, so there is no current tenant, and they have to carry the tenant id explicitly.

Red flag answer: “Every query filters by tenant id.” - This is correct, but you can’t enforce it. It relies on every developer remembering it forever, and when someone forgets, you get a silent data leak instead of an error.

Follow-up: “A global query filter is on. Name three ways a developer can still read another tenant’s data.”

Q20. Design Rate Limiting That Is Fair Across Tenants and Survives a Redis Outage.

Senior

Let’s start with fairness. The limiter has to be partitioned by tenant, instead of being one global limit. A global limit means the loudest tenant consumes the budget and quiet tenants get throttled for someone else’s traffic. ASP.NET Core’s built-in rate limiting middleware is partitioned by design, so the tenant identifier becomes the partition key and each tenant gets its own bucket. The algorithm depends on the shape of the traffic: token bucket when bursts are legitimate, sliding window when the quota is contractual and needs to be accurate.

Then there is the constraint that makes this a system design question, and not just a configuration question. The rate limiting middleware builds its limiters as in-process objects, so the counters are in-memory and per instance. The docs never advertise a distributed mode because there isn’t one. Five instances each enforcing 100 requests per minute means the tenant actually gets 500. If the limit is a real contractual quota, the counter has to move to a shared store, and now Redis is on the request path for every call.

This is what the second half of the question is about: do you fail open, or fail closed? I would fail open for a limiter protecting against accidental overload, because taking the whole API down to enforce a quota is a worse outcome than briefly allowing too many requests. I’d fail closed only where the limit protects something that must not be exceeded, such as a paid quota with real cost behind it.

The design I would actually propose keeps a local in-memory limiter as a cheap backstop even when Redis is healthy. During a Redis outage, it limits the damage to a multiple of the intended rate instead of letting traffic through without any limit, and it keeps the fast path off the network for callers nowhere near their limit.

Red flag answer: “Use the built-in rate limiter middleware.” - It’s the right tool, but it silently multiplies the limit by the number of instances. This is exactly the answer the interviewer is hoping for, so that they can ask what happens when you scale out.

Follow-up: “You fail open. A tenant notices, and starts sending traffic timed to your Redis restarts. Now what?”

Q21. Design an Idempotent Write API, and Prove the Race Is Closed.

Senior

Idempotent means applying the same request twice has the same effect as applying it once. It matters wherever a retry is possible and a side effect is real, which for a payment endpoint is always, because a client that times out can’t tell whether the charge happened.

Here is how it works. The client generates an idempotency key, typically a GUID, and sends it as a header. The server stores that key alongside the result of the operation. If the key arrives again, the server returns the stored result rather than reprocessing.

The proof is the part that matters most in this question, because the naive implementation has a race condition. “Check whether the key exists, and if not, process” is a check-then-act pattern, and two concurrent retries can both pass the check before either of them writes. Then both of them charge the card. To close it, the key insert and the side effect have to commit together, in a single transaction, with a unique constraint on the key so the database, rather than the application, decides the winner. The loser gets a constraint violation and reads the winner’s stored result.

public async Task<ChargeResult> ChargeAsync(string idempotencyKey, ChargeRequest request)
{
await using var tx = await db.Database.BeginTransactionAsync();
// Unique index on IdempotencyKey - the database arbitrates, not application code
db.ProcessedCharges.Add(new ProcessedCharge(idempotencyKey));
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException) when (IsUniqueViolation())
{
// Someone else won the race. Return what they produced.
return await ReadStoredResultAsync(idempotencyKey);
}
var result = await paymentGateway.ChargeAsync(request);
await StoreResultAsync(idempotencyKey, result);
await db.SaveChangesAsync();
await tx.CommitAsync();
return result;
}

There is still one hole left. The gateway call is outside the database’s transaction, so a crash between charging and committing reopens the window. That’s why the gateway call should carry the same idempotency key, pushing the final guarantee to the system that owns the money.

Red flag answer: “Check if a payment with that key already exists, then insert if not.” - This is the check-then-act race, which is exactly the failure this design is meant to prevent. Under concurrent retries, both requests pass the check.

Follow-up: “How long do you keep idempotency keys, and what happens to a client that retries after you expired one?”

Q22. Design Upload and Download for 5 GB Files.

Mid

At 5 GB, the file should never pass through the API at all. Proxying it means the request is held for the duration, the API’s memory and bandwidth are consumed by bytes it doesn’t process, and a failure at 90% restarts from zero.

So the API’s job is to authorize the transfer, and not to carry the bytes. The client asks for permission to upload, the API validates and returns a time-limited pre-signed URL, and the client uploads directly to blob storage using multipart or chunked upload so a failed chunk retries alone. When the upload completes, the client notifies the API or storage fires an event, and only then does the API record the file and start any processing. Download works the same way in reverse: a short-lived signed URL, and never a stream through the API.

Validation is the interesting part, and it’s where interviewers who care about security will push you. Since the bytes never touch the API, the API can’t inspect them before they land. So validation moves to after the upload and before the file is marked usable: check the real content type rather than trusting the extension or the client-supplied header, enforce a size cap in the signed URL policy itself, and scan for malware if the file will ever be served back to another user. Until that pipeline passes, the file stays quarantined and unreferenced.

Red flag answer: “Post it to an endpoint with [RequestSizeLimit] raised.” - This works on localhost during development, but fails behind every real proxy. It also can’t resume a failed upload, and memory usage grows with the file size.

Follow-up: “The client uploads to the signed URL and then never calls back. How do you find that file, and how long does it live?”

Q23. Design Versioning for a Public API With 200 Consumers.

Senior

With 200 consumers, the versioning mechanism isn’t the hard part. The hard part is that you can never get all of them to move at the same time, so the design should make sure most changes don’t need a new version at all.

So my first rule is to make additive changes by default. Adding a field, an optional parameter, or an endpoint doesn’t need a new version, as long as clients are tolerant readers. Most “breaking changes” I’ve seen were avoidable, and each unnecessary version is a copy of the surface area that has to be maintained for years.

When a change really does break, URL path versioning is my default for a public API. It’s visible in logs, easy to cache, obvious in documentation, and a consumer can tell what they’re calling by reading the URL. Header versioning looks cleaner on paper, but for a public API it’s worse in practice, because the version is hidden in every place you debug from.

There are a few things people usually forget, and I would bring them up:

  • A written deprecation policy with a real date. If you say “we’ll support it for a while”, nobody will ever migrate.
  • Telemetry per version, per consumer. You can’t retire v1 until you know exactly who is still using it.
  • A hard limit on how many versions you run at once. Each version adds maintenance work and security risk.

Red flag answer: “Version every endpoint from day one, just in case.” - This multiplies the surface area before there is even a single consumer to break. Also, once versioned endpoints exist, their behavior tends to drift apart over time.

Follow-up: “You want to retire v1 in 90 days. Eleven consumers are still calling it, and two haven’t replied to any email. What do you do?”

Q24. Design Search Over 10 Million Records. When Does SQL Stop Being Enough?

Senior

Later than most people think. Ten million rows isn’t a large table, and if the search is structured, meaning filters, sorts, and exact matches, a relational database with the right indexes handles it comfortably. My first step would be to check whether this is really a search problem, or just an indexing and pagination problem that looks like one.

SQL stops being enough when the requirements are about language instead of structure. That means things like relevance ranking, fuzzy and typo-tolerant matching, stemming across languages, faceted counts over a filtered set, and autocomplete on partial input. A LIKE '%term%' query doesn’t just do these badly. It can’t do them at all, and a leading wildcard can’t use an index anyway, so the query degrades to a scan that gets slower every month.

At that point, I would add a dedicated search index next to the database, instead of replacing the database. The relational store stays the source of truth, and the index is a derived read model kept in sync by the write path or by change events.

This brings in the real cost, and it’s what I’d want a candidate to bring up: the index is now a second copy of the data, and the two can drift apart. Sync lag means a record can be searchable before it’s readable, or readable before it’s searchable. There must be a reconciliation path for when the sync fails, because “rebuild the whole index” at 10 million records isn’t an operation you want to discover the runtime of during an incident.

Red flag answer: “Use Elasticsearch, it’s built for search.” - This might be the right tool, but the answer doesn’t justify it. It also skips the question of whether an index on the existing table would have solved the problem without any new infrastructure.

Follow-up: “A user creates a record and immediately searches for it. Does it appear? What did you just promise them?”

Read next

Rate Limiting in ASP.NET Core (.NET 10)

The built-in partitioned limiter behind Q20, including why its state is per instance.


Consistency, Failure, and Cost

This is the last category, and it’s usually the one that decides between an offer and a rejection. Most candidates can design the happy path. Senior candidates also design what happens when things break, and they know what their design costs.

Q25. Two Users Edit the Same Record. Who Wins, and How Do You Tell Them?

Senior

In most systems, last write wins by default, even though nobody actually decided that it should. Two users open a record, both save, and the second save silently erases the first user’s changes, with no error and no trace. It’s a data loss bug that leaves no logs behind, which is why I would call it out as a decision instead of quietly accepting it as the default.

My default is optimistic concurrency: the record carries a version, the update asserts the version that was read, and a mismatch is a conflict. The whole point is that silent data loss now becomes something you can see.

What to do with the conflict is a product question, and I would say that in the interview instead of guessing. There are three options, and I would walk the interviewer through the trade-offs instead of pretending there is only one answer:

  • Reject the save and make the user reload. This is simple, but the user loses what they typed.
  • Merge automatically when the two users changed different fields. This is what most people actually expect, but it needs field-level tracking instead of record-level tracking.
  • Show both versions and let the user choose. This is the most accurate option, and also the most expensive to build.

One consequence is worth stating clearly: if merging is ever going to be a requirement, you have to design for it from the start. Field-level change tracking can’t be added later without changing the schema and the API, so answer this question before the first version ships, and not after the first complaint.

Red flag answer: “The database handles it with row locks.” - Row locks only last as long as the transaction, which is milliseconds. The conflict here spans the minutes a user spends typing, and no database lock covers that.

Follow-up: “You chose auto-merge on non-overlapping fields. Both users edited the same field. Now what?”

Q26. A Downstream Dependency Is Down for Three Hours. Design for It.

Senior

The first question is what the dependency does, because that is what decides the strategy. Before designing anything, I would sort every dependency into one of three buckets.

If it’s on the critical path and there’s no meaningful degraded mode, the right design is to fail fast and clearly. A payment gateway outage means checkout is down. If you try to hide that, you end up with orders the system can’t fulfil, which is worse than showing an error message.

If the result can be stale, serve the stale data. During an outage, cached data past its expiry is almost always better than an error page. This is where a cache with a long backstop expiry proves its value, and it has nothing to do with performance.

If the call can wait, let it wait. Anything that doesn’t need an immediate answer, like sending an email or syncing to a downstream system, should be queued. That way, a three-hour outage just becomes a three-hour backlog that clears on its own.

All three buckets need the same protections:

  • A timeout on every call. A hung call holds a request thread, and that turns one slow dependency into an outage across the whole fleet.
  • A circuit breaker, so the system stops making calls it already knows will fail.
  • Degraded-mode behavior that you designed and tested up front, instead of finding out how it behaves during the incident.

I would close with this point: at three hours, retries don’t matter anymore. Retry policies are sized for seconds. What matters over hours is whether the system stays partially useful, and that is a design decision you make long before the incident.

Red flag answer: “Retry until it comes back.” - Three hours of retries against a dead dependency is basically a load test you run against your own thread pool, and every one of those requests is holding resources.

Follow-up: “It comes back after three hours. Your queue holds 400,000 deferred calls. What happens in the next 60 seconds?”

Q27. Design a Health Check That Actually Means Something to a Load Balancer.

Mid

The important difference is between “should this instance receive traffic” and “should this instance be restarted”. Mixing up the two causes outages.

A liveness check answers whether the process is broken beyond recovery. It should be nearly trivial and shouldn’t touch dependencies. If liveness fails, the orchestrator kills the pod, so if the liveness check pings the database, a short database blip restarts every pod at once. A system that was only degraded is now completely down.

A readiness check answers whether this instance can serve right now. This one can check dependencies, because failing readiness removes the instance from rotation without killing it. When the dependency recovers, the instance comes back on its own.

Here is the detail that makes an answer strong: readiness should only check dependencies that the instance can’t work without. If a service can serve most traffic with a cache down, cache health doesn’t belong in readiness, because failing readiness on every instance for a degraded-but-usable dependency takes the whole service out of rotation. In that case, your own health check caused the outage.

ASP.NET Core supports this directly with tagged checks and separate health check endpoints, so liveness and readiness map to different filters over the same registrations.

Red flag answer: “One /health endpoint that checks the database, cache, and message broker.” - If this is wired to liveness, it restarts the entire fleet the moment any shared dependency has a hiccup.

Follow-up: “Your readiness check fails on all instances at once. What does the load balancer do, and is that what you wanted?”

Q28. Design the Observability for This System. What Do You Instrument First?

Senior

I’d instrument what the user experiences first, because that is what tells you something is wrong. Request rate, error rate, and latency at p50, p95, and p99, per endpoint. Even if you have only those four and nothing else, you still know when the system is broken and roughly where, which is more than most systems can tell you.

Next come the saturation signals for whatever the system’s actual bottleneck is: thread pool queue depth, database connection pool usage, queue depth for background work. These tell you why something is wrong, and they can warn you about an outage before users feel it.

On logging, the rule I care about is that logs must be structured and correlated. A log line without a correlation id is nearly useless in any system with more than one moving part, because you can’t reconstruct what happened to one request. Structured fields turn logs from text you grep into data you can query, and that can turn an afternoon-long investigation into a five-minute one.

Here is the rule I follow, and interviewers listen for it: alert on symptoms, and not on causes. Alerting on high CPU produces pages for things that are fine. Alerting on “error rate above 2% for five minutes” produces pages for things that are actually broken. And I’d sample traces rather than keep them all, because full-fidelity tracing at volume costs more than the system it observes.

Red flag answer: “Log everything and set up dashboards.” - This collects a lot of data without any question in mind. You usually end up with 40 GB of logs every day, and still nobody can find out why a specific request failed.

Follow-up: “You get one alert. Just one, for the whole service. What is it?”

Q29. Run This on $200 a Month Instead of $2,000. What Do You Cut?

Senior

I like this question because cost is a real design constraint that almost nobody prepares for. The answer shows whether someone has actually owned a system in production, or only built one.

First, I would find out where the money actually goes, because gut feeling is usually wrong here. In most systems I have seen, the bill is dominated by a small number of line items: managed database tier, always-on compute sized for peak, data egress, and log and telemetry retention. Anything else you optimize barely moves the bill.

Then I would make the cuts in order of how much they save compared to how much risk they add:

  1. Log and metric retention. 30 days of full-fidelity telemetry is usually 90% waste, and cutting it to 7 days with sampling changes almost nothing day to day.
  2. Right-sizing compute. Most services are provisioned for a peak that happens twice a day, and sit idle the rest of the time.
  3. Consolidating managed services. A separate Redis, a separate search cluster, and a separate broker each come with a baseline cost, and at small scale, one database can often do two of those jobs well enough.

What I wouldn’t cut are backups, and the redundancy that keeps the system available. Those cuts save real money, right up until the day they cost the company far more than they ever saved.

From an architecture point of view, the cheapest system at small scale is often the simplest one. A lot of infrastructure cost is really the cost of running a distributed design that was adopted before there was a reason for it. That is the case against microservices, showing up on your invoice.

Red flag answer: “Move to serverless, you only pay for what you use.” - This is sometimes true, but often it’s the opposite. A steady, predictable load is usually cheaper on reserved compute. Also, the migration cost is real, while the savings are only a guess.

Follow-up: “You cut retention to 7 days. An incident happens that started 12 days ago. What do you tell the post-mortem?”

Q30. Forty-Five Minutes and a Whiteboard: Design a Ticket Booking System in .NET.

Senior

This is the final question that pulls everything together, and the easiest way to fail it is to start drawing boxes straight away. I would spend the first five minutes on requirements, because the whole design depends on details that the interviewer is deliberately holding back.

Here are the questions I would ask: how many events and how many seats each, is seating assigned or general admission, how long does a user hold a seat while paying, and what’s the peak. “Ten thousand people hitting the same event at 10am on release day” is a completely different system from “steady traffic across a thousand events,” and the interviewer usually has one of them in mind.

Assuming assigned seating with a release-day spike, the core of the design is the seat hold, and everything else is fairly standard. A seat has three states: available, held, and sold. A hold is a row claimed atomically with an expiry, so a user who abandons checkout releases the seat without anyone doing cleanup. Claiming has to be a single atomic operation with a condition on the current state, because check-then-act here sells the same seat twice, which is the failure this system exists to prevent.

The release-day spike is the second design problem, and it’s a different kind of problem. With ten thousand concurrent users and only a few thousand seats, most requests have to fail, and failing fast matters more than being clever. A queue in front of the sale works better than trying to make the seat-claim path infinitely scalable, because the real constraint is that seats are a finite serialized resource and no amount of horizontal scaling changes that.

Payment comes after the hold. The seat is held, payment is attempted, and only a successful payment moves the seat from held to sold. Payment must carry an idempotency key, because a client retry that double-charges for one seat is the worst bug this system can have. That is Q21 showing up again inside a bigger design, which is exactly what this final question is testing.

Then I would point out the two things I deliberately kept simple. Reads scale easily with caching, since event and seat-map data barely changes. And everything after the sale, meaning confirmation emails and downstream syncing, goes on a queue because none of it needs to happen inside the request.

Red flag answer: Drawing the architecture before asking what the system is for. You end up with fifteen boxes and a message broker, when the interviewer was about to say “general admission, 200 seats, one event a week.”

Follow-up: “Your hold expires after 10 minutes. A user is on the payment page at minute 11 and the payment succeeds. What happens?“


5 System Design Mistakes That Get .NET Developers Rejected

The same five mistakes show up again and again in this round:

  1. Listing options instead of choosing one. “You could use A, B, or C” sounds knowledgeable, but it gets scored as indecisive. Every answer above starts with a default, because picking one is exactly what this round measures.
  2. Designing only the happy path. When a candidate explains, without being asked, what happens when the cache is down, the broadcast is lost, or the job dies halfway through, that is what separates a senior answer from a mid-level one. Most of the interview is really about the failure path.
  3. Adding infrastructure before diagnosing the problem. “Add Redis,” “add a queue,” “add Elasticsearch,” before finding out what is actually slow. Q2 and Q24 are both traps for this, and interviewers set them on purpose.
  4. Ignoring the instance count. BackgroundService runs on every instance. The rate limiter keeps its state in memory, per instance. A SignalR connection lives on one server. HybridCache collapses stampedes per machine. Almost every .NET-specific trap in this round is the same trap: an answer that works on one machine and breaks on three.
  5. Being vague about scale. Saying “it’ll scale” or “we’d add more servers” without naming the bottleneck. Saying “at 100,000 connections, the limit is memory per connection, and not CPU” is worth more than any architecture diagram.

Key Takeaways

  • Name a default, then name its boundary. This is what the round grades. “HybridCache by default, and plain IMemoryCache when the data is genuinely per-instance” is better than any list of options.
  • The instance-count trap is the most common .NET-specific question. Background services, rate limiter state, SignalR connections, and cache stampede protection all behave differently on three machines than on one, and interviewers know exactly where those differences are.
  • Designing for failure is what makes you sound senior. Fail open or fail closed, what happens when the broadcast is lost, what the user sees when retries run out. Bringing these up on your own is the quickest way to take the conversation to the next level.
  • Diagnose before you add infrastructure. Caching an N+1, adding a search cluster instead of an index, scaling the API tier when the database is the bottleneck - these are the traps, and they’re set on purpose.
  • Cost and operations are part of the design. Retention, right-sizing, and what you wouldn’t cut are all fair questions at the senior level, and almost nobody prepares for them.
Free resource Companion download

.NET Interview Questions

300+ real .NET interview questions with answers, red flags, and follow-ups - C#, EF Core, ASP.NET Core, system design

What do .NET system design interviews cover?

They cover product-level design scenarios answered with a concrete .NET stack: caching strategy and read scale, write throughput and data contention, background and scheduled work, real-time push, multi-tenancy, rate limiting, idempotency, file handling, and failure design. Questions are open-ended scenarios rather than definitions, and the interviewer grades whether you can name a default, explain the mechanism, identify the boundary where the default stops applying, and describe the failure path when a dependency goes down.

How do I prepare for a system design interview as a .NET developer?

Practice answering with a named default rather than a list of options. For each area, decide in advance what you would reach for first and what would change your mind. Learn the behavior of the common .NET building blocks when the system runs on more than one instance, because that is where most interview traps are set: background services run on every instance, the built-in rate limiter keeps state per instance, SignalR connections terminate on a single server, and HybridCache stampede protection is per machine. Then practice saying the failure path out loud, since most candidates never volunteer it.

Are system design questions asked for mid-level .NET roles or only senior?

Both, at different depths. Mid-level rounds ask scoped design questions such as how to cache a read-heavy endpoint or how to move a slow operation off the request path, and a correct mechanism is usually enough. Senior rounds ask the same scenarios and then push on trade-offs, failure modes, and cost, expecting you to volunteer what breaks before being asked. The weighting rises sharply with seniority, and for senior and lead roles this round often carries more weight than the coding round.

Do I need to know distributed systems theory to pass a .NET system design round?

You need the practical consequences, not the formal theory. Knowing that a cache can serve stale data, that at-least-once delivery means consumers must be idempotent, that a lease can expire mid-job, and that two instances can both pass a check-then-act test will carry almost every question. Being able to state the CAP theorem adds little on its own. Interviewers are testing whether you have reasoned about these failures in a running system, not whether you can name them.

Should I design in .NET-specific terms or stay language-agnostic in the interview?

Lead with the concept, then name the concrete .NET component. Say what the design needs and why, then say what you would use to build it: HybridCache for two-tier caching, Hangfire for durable jobs, the built-in partitioned rate limiter, Server-Sent Events or SignalR for push. Answers with only concepts sound theoretical, and answers with only product names sound like you memorized a list of tools. Naming the specific component also signals current knowledge, which matters because interviewers notice when the tooling in your answer is from several .NET versions ago.

How long is a typical .NET system design round and what is expected on the whiteboard?

Most run 45 to 60 minutes for one scenario. Spend the first five minutes clarifying requirements, since the interviewer usually withholds the constraint that determines the design, such as expected scale, staleness tolerance, or whether seating is assigned. Then sketch the main components and the data flow between them, and go deep on the one or two hard parts rather than drawing every box. The interviewer doesn't grade how neat your diagram is. They grade the reasoning you explain while drawing it.

What is the most common mistake .NET developers make in system design interviews?

Assuming a single instance. Background services run on every instance, so a scheduled job fires once per replica. The built-in rate limiter keeps its counters in memory per instance, so five instances enforce five times the intended limit. SignalR connections terminate on one server, so a message published on another never reaches them. HybridCache collapses concurrent callers per machine, not across the cluster. Each of these is correct on a laptop and wrong in production, and interviewers ask about them specifically.

Is HybridCache or Redis the right answer for caching in a system design interview?

HybridCache is the better default for a new .NET 10 service, and Redis is usually part of the answer rather than an alternative to it. HybridCache provides an in-process L1 backed by MemoryCache and an L2 backed by whatever IDistributedCache is registered, which is often Redis, so most reads are served from memory while all instances still agree through the shared tier. It also provides stampede protection per instance. Choose plain IDistributedCache with Redis when only the shared tier is needed or when something outside the application reads the same cache, and plain IMemoryCache when the data is genuinely per instance and cheap to rebuild.

Troubleshooting Your Own Answers

Here are the common ways these answers go wrong in a live interview, and how to fix each one:

  • You gave a list instead of a decision. If your answer contains “you could” more than once, stop and pick one. Say “I would start with X” and let the interviewer push back. Getting pushed back on is a normal part of the conversation, and it doesn’t mean you failed.
  • You designed for one instance without noticing. Before finishing any answer involving background work, caching, rate limits, or connections, ask yourself what changes at three instances. If the answer is “nothing,” check again.
  • You added a dependency without a failure plan. The moment you say “Redis” or “a queue,” the next sentence should be what happens when it’s down. If you don’t say it, that will be the follow-up question.
  • You skipped the requirements. If you started drawing within the first minute, you’re designing for a system the interviewer didn’t describe. Two clarifying questions take about 60 seconds, and they can change the whole design.
  • You claimed scale without a constraint. If you say “it scales horizontally”, the interviewer will ask what doesn’t. Naming the bottleneck yourself, whether that is connections, the database, or a serialized resource like seat inventory, puts you in a much stronger position than having the interviewer point it out.

Wrapping Up

If you look at all 30 questions, you will notice the same pattern. Interviewers already assume you know the components. What they are checking is whether you can commit to one, explain how it works, and then describe the exact conditions under which your own choice becomes the wrong one. That last part is what a senior answer sounds like, and it’s also why “it depends” scores so badly. It sounds careful, but it doesn’t actually tell the interviewer anything.

The .NET-specific part of this round is smaller than it looks, and it’s easy to prepare for. You need to know what changes when the system runs on more than one instance. Background services, rate limiter state, SignalR connections, and cache stampede protection all behave differently at three replicas than at one, and that gap is where most of these interviews are decided.

If you want to practice under time pressure instead of reading, the free mock interview scores you per topic and shows where the gaps are.

This is one spoke of my broader interview prep series. Start at the .NET interview questions hub for the cross-topic set, go deeper on runtime and production debugging with the senior .NET developer interview questions, and cover distributed systems with the .NET microservices interview questions.

If this helped, bookmark it for the night before your interview, or send it to someone prepping for theirs. And if a question here exposed a gap you want covered properly, tell me which one and I’ll write it.

Good luck with the interview.

Happy Coding :)

View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Newsletter

stay ahead in .NET

One email every Tuesday at 7 PM IST. One topic, deep. The week's articles. No filler.

Tutorials Architecture DevOps AI
Join 9,735 developers · Delivered every Tuesday
Privacy notice 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →