Interview Questions
Common iOS, Android, backend and general developer interview questions with model answers, what interviewers look for, and flashcards.
Tip: use ← and → keys
01How is memory managed in Swift compared to Kotlin or Java?
LanguagesMid-level
How is memory managed in Swift compared to Kotlin or Java?
LanguagesMid-level
In shortSwift uses Automatic Reference Counting, which frees an object as soon as its last strong reference goes away. Kotlin on the JVM and Java use a garbage collector that periodically frees unreachable objects.
Swift: Automatic Reference Counting (ARC). The compiler inserts retain and release calls. Every class instance counts its strong references, and when the count reaches zero it is freed right away. Memory use is predictable and there are no collection pauses.
The weak spot is retain cycles: if two objects reference each other strongly, neither count reaches zero. You break the cycle with weak (becomes nil when the object is freed) or unowned (assumed to always exist), most often as [weak self] in closures.
Kotlin and Java: garbage collection. The JVM (or Android’s ART) periodically starts from roots such as threads and static fields and frees everything it can’t reach. Cycles are not a problem, because unreachable cycles are collected too. The cost is that collection runs when the runtime decides, which can mean short pauses and more memory headroom.
| ARC (Swift) | GC (JVM, ART) | |
|---|---|---|
| When memory is freed | Immediately at zero references | When the collector runs |
| Reference cycles | Leak unless broken with weak | Collected automatically |
| Runtime cost | Small, spread across every assignment | Periodic, can cause pauses |
A good closing line: “Both are automatic, but with ARC I still have to think about ownership; with a GC I think more about allocation pressure and pause times.”
What they're looking for
That you know both models, the trade-off between predictability and convenience, and the retain cycle problem that only ARC has.
Common mistakes
- Saying Swift has a garbage collector.
- Not mentioning retain cycles and how weak or unowned references fix them.
- Claiming garbage collection has no cost.
02What is the difference between a value type and a reference type?
LanguagesJunior
What is the difference between a value type and a reference type?
LanguagesJunior
In shortAssigning a value type copies the data, so each variable is independent. Assigning a reference type copies a reference, so several variables share one object and see each other's changes.
With a value type, the variable holds the data itself. Assigning it or passing it to a function makes a copy, and changing the copy never affects the original. With a reference type, the variable holds a reference to an object; assignment copies the reference, so both variables point to the same object.
struct Point { var x = 0 } // value type
final class Counter { var n = 0 } // reference type
var a = Point(); var b = a; b.x = 5 // a.x is still 0
let c = Counter(); let d = c; d.n = 5 // c.n is now 5
Per language:
- Swift:
struct,enum, tuples,Stringand the collections are values (collections use copy-on-write, so copies are cheap until changed).classandactorare references. - Kotlin and Java: only primitives (
Int,int,Boolean…) behave as values. Everything else, including Kotlindata classand Javarecord, is a reference type. They get value-like behavior when they’re immutable.
Why it matters: shared mutable objects cause “action at a distance” bugs, where code in one place changes data another place is using. Values (or immutable objects) make that impossible and are safe to share between threads.
What they're looking for
A clear definition with an example of the different behavior after assignment, the right types per language (Swift struct vs class, JVM primitives vs objects), and why it matters for bugs. Bonus for mentioning immutability and copy-on-write.
Common mistakes
- Saying Kotlin data classes or Java records are value types.
- Answering only with "stack vs heap", which is about memory, not semantics.
- Not mentioning what happens when you change a shared object.
03What is the difference between checked and unchecked exceptions, and how do Kotlin and Swift handle this?
LanguagesMid-level
What is the difference between checked and unchecked exceptions, and how do Kotlin and Swift handle this?
LanguagesMid-level
In shortIn Java, checked exceptions must be caught or declared with throws, and unchecked ones (RuntimeException) don't. Kotlin has only unchecked exceptions. Swift marks functions with throws and every call with try, but errors aren't typed unless you use typed throws.
Java splits exceptions in two:
- Checked (extend
Exception): the compiler forces callers to catch them or declarethrows. Meant for recoverable situations the caller should plan for, likeIOException. - Unchecked (extend
RuntimeException): no enforcement. Meant for programming errors, likeNullPointerExceptionorIllegalArgumentException.
The idea is good, but in practice checked exceptions don’t work with lambdas (stream().map(this::load) can’t throw IOException), they leak implementation details up through layers, and they encourage empty catch blocks. Modern code often wraps them at the edges (UncheckedIOException), always keeping the original as the cause.
Kotlin has no checked exceptions at all. For expected failures, Kotlin code often returns null or a sealed result type instead, and uses @Throws so Java callers can catch exceptions.
Swift takes a middle path: throws in the signature and try at every call site make failure visible, but the error type is any Error unless you use typed throws (throws(ParseError), Swift 6). Swift errors are ordinary return values under the hood, so throwing is cheap.
In all three, cancellation (CancellationError, CancellationException, InterruptedException) travels through the error path and must be propagated, not handled as a failure.
What they're looking for
The Java rule and the reason behind it (recoverable vs programming errors), the practical problems with checked exceptions (lambdas, leaking details), how Kotlin and Swift differ, and good habits like keeping the cause when wrapping.
Common mistakes
- Claiming Kotlin has checked exceptions, or that Swift errors are exceptions with stack unwinding costs like Java's.
- Recommending catch (Exception e) {} or wrapping without the cause.
- Not knowing that lambdas in streams can't throw checked exceptions.
04What are type erasure and variance in generics, and how do Swift, Kotlin and Java differ?
LanguagesSenior
What are type erasure and variance in generics, and how do Swift, Kotlin and Java differ?
LanguagesSenior
In shortJava and Kotlin erase generic type arguments at runtime while Swift keeps them. Variance decides whether a container of a subtype counts as a container of its supertype; Kotlin declares it with out and in, Java uses wildcards, and Swift generics are invariant except for built-in collections.
Type erasure: Java and Kotlin check generic types at compile time and drop the type arguments in the bytecode. At runtime ArrayList<String> and ArrayList<Integer> are the same class, so you can’t write x instanceof List<String>, new T(), T.class, or two overloads that differ only by type argument, and JSON libraries need a TypeToken/TypeReference to learn that a list holds Users. Kotlin’s inline fun <reified T> keeps T at the call site. Swift keeps generic types at runtime and can specialize generic code per type.
Variance: if Cat is an Animal, may a container of cats be used as a container of animals?
- Reading only (producer): yes, that’s covariance. Kotlin:
List<out E>. Java:List<? extends Animal>. - Writing only (consumer): the other way round, contravariance. Kotlin:
Comparator<in T>. Java:List<? super Cat>. - Both: neither; invariant.
MutableList<Cat>isn’t aMutableList<Animal>, or you could add aDogto a list of cats.
Java’s rule of thumb is PECS, producer extends, consumer super. Swift generics are invariant, with the standard collections special-cased ([Cat] converts to [Animal] safely because arrays are values).
Senior bonus: Swift’s some P is one hidden concrete type checked statically, while any P is an existential box with dynamic dispatch and possible heap allocation, so prefer generics and some in hot code.
What they're looking for
Concrete consequences of erasure (no T::class, TypeToken, no overloads by type argument), the read/write reasoning behind variance, PECS, Kotlin out/in and reified, and Swift's reified generics and some/any.
Common mistakes
- Saying all three languages erase generics.
- Not being able to explain why MutableList<Cat> isn't a MutableList<Animal>.
- Confusing Swift's some and any.
05When would you choose composition over inheritance?
LanguagesMid-level
When would you choose composition over inheritance?
LanguagesMid-level
In shortUsually. Inheritance couples a subclass to its parent's internals and multiplies classes when behavior varies along several dimensions; composition gives an object the parts it needs, each replaceable on its own. Keep inheritance for stable is-a relationships.
Inheritance gives you code reuse and a subtype in one step, and that’s its problem: the subclass depends on how the parent is implemented. Change the parent (which methods call which) and subclasses can break without being touched. It also scales badly: notifications by channel × urgency × timing become ScheduledUrgentPushNotification and a dozen siblings.
Composition gives an object the parts it needs: a notification has a channel, an urgency and a timing. Each part varies on its own, is replaceable in tests, and adding a channel is one new type.
Inheritance is still right for a genuine, stable is-a relationship, or when a framework requires it (subclassing a view or an Activity). Even then, mark classes final (Kotlin’s default) unless they’re designed to be extended, and avoid calling overridable methods from constructors.
What they're looking for
The coupling cost of inheritance (fragile base class), the combinatorial explosion example, delegation as the alternative, and a case where inheritance is still right.
Common mistakes
- Saying inheritance is always bad.
- Not giving a concrete example.
- Ignoring language defaults (Kotlin final classes, Swift final and value types).
06What is a closure, and what does it capture?
LanguagesJunior
What is a closure, and what does it capture?
LanguagesJunior
In shortA closure is a function that can use variables from where it was defined and keeps them alive. Swift, Kotlin and JavaScript capture the variables themselves (later changes are visible); Java captures only effectively final values. Captures are also why closures cause retain cycles and leaks.
A closure (or lambda) is a function value that can use variables from the scope where it was created:
fun makeCounter(): () -> Int {
var count = 0
return { ++count } // captures count; it lives on after makeCounter returns
}
What is captured differs by language. Swift, Kotlin and JavaScript/TypeScript capture the variable: if it changes later, the closure sees the new value, and the closure can change it. Swift’s capture list ([x]) takes a snapshot instead. Java lambdas may only use effectively final locals, captured by value, so changing state has to live in an object.
Why it matters: a closure keeps everything it captures alive. In Swift, a closure stored on self that uses self is a retain cycle (fix: [weak self]). On the JVM and in JavaScript, a callback registered with something long-lived (a singleton, a timer, a global event) keeps the whole screen alive until it’s removed.
What they're looking for
A plain definition with an example (a counter or a callback), the difference in capture semantics between languages, and awareness of the memory consequences.
Common mistakes
- Confusing closures with anonymous classes only.
- Not knowing that captured variables live as long as the closure.
- Missing the retain cycle ([weak self]) or listener-leak consequence.
07What makes a unit test good?
LanguagesJunior
What makes a unit test good?
LanguagesJunior
In shortIt checks one behavior through the public API, uses an expected value derived from the requirement, runs fast and deterministically with fakes for the network, clock and storage, and would actually fail if the code were wrong.
A good unit test:
- Checks one behavior and is named after it, so a failure says what broke.
- Arranges input, acts once, asserts against an expected value worked out from the requirement, never recomputed with the code’s own formula.
- Covers the cases that find bugs: a normal case, boundaries on both sides (7 and 8 characters for an 8-character minimum), empty and invalid input, and error paths. Parameterized tests make that cheap.
- Is isolated and deterministic: slow or random dependencies (network, database, clock, randomness) are replaced with fakes passed in through the constructor, and no state is shared between tests, so it passes alone, together, in any order and at any time.
- Can fail: break the code once and watch it go red. Async tests must await their work, or they pass no matter what.
It tests behavior through the public API rather than private methods, so refactoring doesn’t break it.
What they're looking for
Arrange-act-assert, choosing cases (boundaries, errors), isolation with test doubles and injected dependencies, and the idea that a test must be able to fail.
Common mistakes
- Equating coverage with quality.
- Testing private methods or implementation details.
- Ignoring flaky tests or "fixing" them with retries.
08What is actor reentrancy in Swift, and how can it cause bugs?
iOSSenior
What is actor reentrancy in Swift, and how can it cause bugs?
iOSSenior
In shortAn actor runs one piece of its code at a time, but every await inside an actor method is a point where other calls can run. State read before an await may have changed after it, causing lost updates and duplicate work.
Actors prevent data races: only one piece of actor-isolated code runs at a time. But they’re reentrant: when an actor method suspends at an await, the actor is free to run other calls, and when the method resumes, the state may be different.
actor ImageCache {
private var images: [URL: Data] = [:]
func image(for url: URL) async throws -> Data {
if let cached = images[url] { return cached }
let data = try await download(url) // other calls run here
images[url] = data // two callers can both reach this
return data
}
}
Ten rows asking for the same avatar all miss the cache before the first download finishes, so the same file is downloaded ten times. A read-modify-write across an await (let v = count; await …; count = v + 1) loses updates the same way.
Fixes:
- Don’t hold state across
await: do the read-modify-write synchronously, or re-read after resuming. - Record intent before suspending: store the in-flight
Taskin the dictionary before awaiting it, so later callers join it. - Keep critical sections synchronous: if a change must be atomic, do it without suspension points.
Swift made actors reentrant on purpose: non-reentrant actors waiting on each other would deadlock. The trade-off is that you reason about each await as a place where the world can change.
What they're looking for
A precise explanation (serialized, not transactional), a concrete example like a cache that downloads the same URL twice, and fixes (no state held across await, re-check after suspension, storing in-flight tasks). Bonus for explaining why actors are reentrant (to avoid deadlocks).
Common mistakes
- Claiming actors make whole methods atomic.
- Proposing a lock held across an await as the fix.
- Not distinguishing a data race (prevented) from a logical race (still possible).
09How can an iOS app do work when it's not on screen?
iOSMid-level
How can an iOS app do work when it's not on screen?
iOSMid-level
In shortOnly through specific mechanisms. beginBackgroundTask for finishing short work, a background URLSession for uploads and downloads, BGTaskScheduler for system-scheduled refresh and processing, and background modes for audio, location or VoIP. The system decides when scheduled work runs.
When the scene goes to the background, the app gets a few seconds and is then suspended; later it may be terminated without notice. So first: save user data at .background.
Then pick the tool for the job:
- Finish something you started:
beginBackgroundTaskgives a short extra window (about 30 seconds, not guaranteed). Always end it, including in the expiration handler. - Transfers: a background
URLSessionwith file-based upload/download tasks. The system continues them while the app is suspended or terminated and relaunches the app when they finish. - Periodic refresh:
BGAppRefreshTaskviaBGTaskScheduler(or SwiftUI’s.backgroundTask(.appRefresh)): short, scheduled by the system based on usage, battery and network. Reschedule on every run. - Heavy maintenance:
BGProcessingTask, usually while charging and idle. - Continuous features: background modes for audio playback, navigation/location, VoIP.
The system decides: earliestBeginDate is only a minimum, force-quit apps don’t get background time, silent pushes are throttled. So refresh on launch too and keep a persistent queue of pending work.
What they're looking for
The right tool for each kind of work, the system's control over scheduling, and the habits that make it reliable (save at background, reschedule, expiration handlers, persistent queues).
Common mistakes
- Assuming a timer or a Task keeps running after the app is backgrounded.
- Expecting refreshes at exact intervals.
- Relying on applicationWillTerminate to save.
10How do you make sure background work in a mobile app stops when the user leaves the screen?
AndroidMid-level
How do you make sure background work in a mobile app stops when the user leaves the screen?
AndroidMid-level
In shortStart the work in a scope tied to the screen's lifetime (viewModelScope, lifecycleScope, SwiftUI's .task), so it's cancelled automatically, and make sure the work checks for cancellation and doesn't swallow it.
Tie the work to an owner whose lifetime matches the work:
- Android:
viewModelScope(cancelled inonCleared()),lifecycleScopewithrepeatOnLifecycle(STARTED)for collecting flows only while visible, andcollectAsStateWithLifecycle()in Compose. - iOS: SwiftUI’s
.taskand.task(id:)(cancelled when the view disappears), structuredasync letand task groups, and storedTaskhandles cancelled indeinitoronDisappearfor anything unstructured.
Cancellation is cooperative in both: it sets a flag and throws CancellationException / CancellationError at the next suspension point that checks. Networking and delay/Task.sleep check for you; long CPU loops need ensureActive() or Task.checkCancellation(). And it travels through the error path, so broad catch blocks (and Kotlin’s runCatching) must rethrow it, or the work keeps running and may show an error for a screen that’s gone.
Work that should outlive the screen (an upload the user already sent, a sync) belongs to an application-level scope, or better, to WorkManager on Android and a background URLSession or BGTaskScheduler on iOS, which also survive the process being killed.
What they're looking for
Structured concurrency on both platforms, the right scope per owner, cooperative cancellation, and the classic mistakes (GlobalScope, Task.detached, catching CancellationException). Bonus for knowing when work should outlive the screen (WorkManager, background URLSession).
Common mistakes
- Using GlobalScope or Task.detached for screen work.
- Catching Exception (or using runCatching) around suspend calls and swallowing cancellation.
- Not knowing that cancellation is cooperative in CPU-heavy loops.
11What's the difference between a configuration change and process death on Android, and what survives each?
AndroidMid-level
What's the difference between a configuration change and process death on Android, and what survives each?
AndroidMid-level
In shortA configuration change (rotation, dark mode) destroys and recreates the Activity while the process lives on, so the ViewModel survives. Process death kills the whole app in the background; only saved instance state (SavedStateHandle, rememberSaveable, the navigation back stack) and persisted data come back.
Configuration change: rotation, dark mode, locale, font size or window size change. The Activity (and its composition) is destroyed and recreated in the same process. Fields and remember are lost; the ViewModel survives; rememberSaveable/onSaveInstanceState values are restored.
Process death: the app is in the background and Android kills the process to free memory, with no callback. When the user returns, Android recreates the last Activity and restores its saved instance state: SavedStateHandle, rememberSaveable, the navigation back stack. The ViewModel, singletons and caches are new and empty.
| Rotation | Process death | |
|---|---|---|
Fields, remember | Lost | Lost |
| ViewModel | Kept | Lost |
SavedStateHandle, rememberSaveable | Kept | Restored |
| Database, DataStore | Kept | Kept |
Design: keep data and in-flight work in the ViewModel, user input and ids in saved state, and important drafts on disk; reload data from saved ids. Test process death with adb shell am kill <package> while the app is in the background, or “Don’t keep activities”.
What they're looking for
What each event is, what survives each (fields, remember, ViewModel, saved state, disk), how to test process death, and how to design screens that rebuild from saved ids.
Common mistakes
- Saying the ViewModel survives process death.
- Testing only rotation.
- Putting large data or secrets in the saved-state Bundle.
12What is the N+1 query problem, and how do you find and fix it?
BackendMid-level
What is the N+1 query problem, and how do you find and fix it?
BackendMid-level
In shortLoading a list with one query and then running another query per item to load related data. Fix it with fetch joins, entity graphs, batch fetching or DTO projections, and catch it by counting queries in tests.
N+1 happens when code loads N entities with one query and then touches a lazy association on each, so Hibernate runs one extra query per entity:
List<Author> authors = authorRepository.findAll(); // 1 query
for (Author a : authors) {
System.out.println(a.getBooks().size()); // + 1 query per author
}
With 10 test rows nobody notices; with 5,000 authors it’s 5,001 round trips.
Fixes, depending on the use case:
- Fetch join or
@EntityGraph: load the association in the same query (select a from Author a left join fetch a.books). Don’t combine a collection fetch join with paging: Hibernate then pages in memory. - Batch fetching (
hibernate.default_batch_fetch_size=50): lazy loads are grouped intowhere author_id in (…)queries, turning N+1 into N/50+1 without changing code. - DTO projections: for read-only endpoints, select exactly the columns you need, often with aggregation (
count(b)), and no entities at all.
Detection: set spring.jpa.open-in-view=false so lazy loading in the web layer fails loudly, enable Hibernate statistics or a datasource proxy in integration tests, and assert a maximum number of statements per request.
What they're looking for
A concrete example with lazy associations, at least two fixes with their trade-offs, the pagination caveat for collection fetch joins, and a way to detect it automatically. Bonus for mentioning open-in-view.
Common mistakes
- Making associations EAGER as the fix.
- Not knowing that paging a collection fetch join happens in memory.
- No idea how to detect it before production.
13What does Spring Boot's auto-configuration do, and how do you change what it set up?
BackendMid-level
What does Spring Boot's auto-configuration do, and how do you change what it set up?
BackendMid-level
In shortBoot ships conditional configuration classes that create beans when a library is on the classpath and you haven't defined your own. Change it with properties and customizers; defining your own bean replaces the auto-configured one entirely.
@SpringBootApplication includes @EnableAutoConfiguration, which loads the auto-configuration classes listed by every jar on the classpath. Each one is guarded by conditions:
@ConditionalOnClass: only if a library is present (Jackson, a JDBC driver, Redis).@ConditionalOnMissingBean: only if you haven’t defined that bean yourself.@ConditionalOnProperty: only if a setting is on.
So adding spring-boot-starter-web gives you Tomcat, Spring MVC and a configured JSON mapper without any code.
Changing it, from lightest to heaviest:
- Properties in
application.yml(server.port,spring.jackson.*). - Customizers, beans that adjust what Boot builds (
Jackson2ObjectMapperBuilderCustomizer,RestClientCustomizer). - Your own bean of the same type. The auto-configured one backs off completely, and its properties stop applying, which is a common surprise.
- Excluding an auto-configuration (
@SpringBootApplication(exclude = …)), rarely needed.
To see what happened and why, run with --debug for the conditions evaluation report, or call /actuator/conditions.
What they're looking for
Conditions (@ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty), the back-off rule, how to debug it (--debug, /actuator/conditions), and preferring properties or customizers over replacing beans.
Common mistakes
- Describing it as "magic" without the conditions behind it.
- Not knowing that your own bean switches off the auto-configured one, including its properties.
- Excluding whole auto-configurations to change one setting.
15Your Spring Boot service in Kubernetes keeps restarting with exit code 137. How do you investigate and fix it?
BackendSenior
Your Spring Boot service in Kubernetes keeps restarting with exit code 137. How do you investigate and fix it?
BackendSenior
In shortExit 137 is a kill by the kernel's OOM killer when the container goes over its memory limit, not a Java OutOfMemoryError. Usually the heap is sized too close to the limit; leave headroom with MaxRAMPercentage around 75% and check non-heap memory with Native Memory Tracking.
What 137 means: 128 + 9, the process got SIGKILL. In Kubernetes the pod status shows OOMKilled: total process memory crossed the container’s memory limit and the kernel killed it instantly, so there’s no Java stack trace.
Why it happens: the JVM uses more than its heap. Metaspace (100–200 MB for a Spring app), the code cache, about 1 MB per platform thread stack, direct buffers for Netty and HTTP clients, and GC structures all live outside -Xmx. A config like -Xmx2g in a 2 GiB container is guaranteed to get killed once the heap fills up.
How to investigate:
- Confirm
OOMKilledinkubectl describe podand look at the memory graph: a slow climb (leak or growing cache) or a spike (a large request, a burst of threads)? - Check the JVM flags actually in effect (
jcmd <pid> VM.flags) and the heap size the JVM picked. - Enable
-XX:NativeMemoryTracking=summaryand comparejcmd <pid> VM.native_memory summarywith the container’s memory usage to see which area grows. - If the heap itself grows, take a heap dump (
-XX:+HeapDumpOnOutOfMemoryError, orjcmd GC.heap_dump) and look for the leak.
Fix: size the heap as a percentage, -XX:MaxRAMPercentage=75, so a quarter of the limit is left for everything else; add -XX:+ExitOnOutOfMemoryError for clean restarts; cap thread pools and off-heap caches. Only raise the limit once you know what the memory is for.
What they're looking for
Knowing 137 means SIGKILL from the cgroup OOM killer, that JVM memory is heap plus metaspace, code cache, thread stacks and direct buffers, container-aware defaults (25% heap), percentage-based sizing, and a measured investigation instead of just raising the limit.
Common mistakes
- Searching the logs for an OutOfMemoryError that never gets written.
- Setting -Xmx equal to the container limit.
- Raising the memory limit without finding what grows.
- Forgetting thread stacks when a pool has hundreds of platform threads.
16A Java service's p99 latency spikes every few minutes. How do you find the cause?
BackendSenior
A Java service's p99 latency spikes every few minutes. How do you find the cause?
BackendSenior
In shortMeasure top-down instead of guessing. Check traces to see whether the time is in this JVM, correlate spikes with GC pauses, CPU and pool waits, then profile with JFR or async-profiler. Fix the workload first; tune the GC only when pauses are the proven cause.
1. Is the time in this JVM? Open traces of slow requests. If the time is in a database query or a downstream call, the JVM isn’t the problem.
2. What happens at the same moment? Put p99 latency next to:
- GC pause times (
jvm.gc.pause, GC logs). A periodic multi-secondPause Fullon G1 means concurrent marking fell behind, often because of humongous allocations. - CPU usage and CPU throttling in the container.
- Connection pool and thread pool waits (HikariCP pending threads, Tomcat busy threads).
3. Profile. Record with Java Flight Recorder (around 1% overhead, can run continuously) or async-profiler during a spike. Read the CPU and allocation flame graphs by width; check lock contention and thread states for time spent waiting.
4. Fix in order of payoff:
- Fix the workload: hot allocations, needless work, N+1 queries, oversized responses.
- Size the heap and container correctly.
- Choose the collector: G1 by default, ZGC for strict latency or very large heaps.
- Change single flags, measuring before and after each one.
What a strong answer adds: allocation rate drives GC frequency, so reducing allocation often helps more than GC tuning. JIT warm-up explains slow first requests after a deploy, and readiness gating hides it from users.
What they're looking for
A methodical order (traces, then saturation signals, then profiling), knowing how to read GC logs and what a G1 full GC means, JFR and flame graphs, allocation rate as the driver of GC, and changing one thing at a time with before and after numbers.
Common mistakes
- Raising the heap or copying GC flags before looking at data.
- Looking only at average latency.
- Ignoring time spent waiting for connection or thread pools, which looks like slowness but uses no CPU.
- Benchmarking a suspect with a hand-written timing loop.
17What is dependency injection, and why would you use it?
Design patternsJunior
What is dependency injection, and why would you use it?
Design patternsJunior
In shortDependency injection means a class gets the objects it depends on from outside instead of creating them itself. It makes code easier to test and to change.
A class depends on other objects to do its job: a repository, an API client, a clock. With dependency injection, it doesn’t create them; it receives them, usually through the constructor.
// Without DI: the ViewModel builds its own repository and can't be tested with a fake
class ProfileViewModel : ViewModel() {
private val repository = ProfileRepository(RetrofitClient.api)
}
// With DI: the repository is passed in
class ProfileViewModel(private val repository: ProfileRepository) : ViewModel()
Why it helps:
- Testing. In a unit test you pass a fake repository that returns fixed data, with no network.
- Flexibility. You can switch from Retrofit to Ktor, or from a real clock to a fixed one, without touching the class.
- Clear dependencies. The constructor shows exactly what the class needs.
Frameworks like Hilt, Koin or Spring create and wire the objects for you, which helps in big apps. But passing a parameter to a constructor is already dependency injection.
What they're looking for
A plain definition, a concrete example with constructor injection, and the testing benefit. Bonus points for knowing DI doesn't require a framework.
Common mistakes
- Defining it only as "using Hilt" or "using Spring".
- Not giving an example.
- Confusing it with the Service Locator pattern.
18When would you use the Builder pattern, and do you need it in Kotlin or Swift?
Design patternsMid-level
When would you use the Builder pattern, and do you need it in Kotlin or Swift?
Design patternsMid-level
In shortUse a builder to create objects with many optional settings in a readable, validated way. In Kotlin and Swift, named arguments with defaults usually replace it, but builders still help for Java interop, DSLs, incremental construction and evolvable public APIs.
A constructor with eight parameters, half of them optional and two of them booleans, is unreadable and easy to call wrong. A builder gives every setting a name, lets you skip what you don’t need, and creates the object in one build() call that can validate the whole combination:
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.build();
In Kotlin and Swift, named arguments with default values solve most of this directly:
val notification = AppNotification(title = "Shipped", body = "Arriving Tuesday", priority = Priority.HIGH)
Builders are still the right tool when:
- Java code calls your Kotlin API (Java doesn’t see default arguments).
- The object is assembled step by step over time (a parser, a multi-screen form).
- You want a DSL: Kotlin type-safe builders, Swift result builders.
- You publish a library and need to add options later without breaking binary compatibility (adding a parameter to a public constructor or data class does).
Whatever the form, keep the result immutable: validate in build(), and copy collections instead of handing over the builder’s own list.
What they're looking for
The telescoping-constructor problem, how a builder solves it (named steps, validation in build, immutable result), awareness that language features make it unnecessary in Kotlin and Swift, and concrete cases where it's still the right tool.
Common mistakes
- Writing Java-style builders for every Kotlin class.
- Forgetting validation in build() or leaking the builder's mutable collections into the product.
- Not being able to name any builder from the platform (OkHttp, AlertDialog, HttpRequest, URLComponents).
19When would you use the Decorator pattern instead of inheritance?
Design patternsMid-level
When would you use the Decorator pattern instead of inheritance?
Design patternsMid-level
In shortWhen behaviors like caching, logging, retries or metrics should be combined freely around an object without changing it. Decorators wrap the same interface and compose; inheritance needs a subclass per combination and is fragile when the base class calls its own methods.
Inheritance adds behavior at compile time, one combination per class: CachedRepository, LoggingCachedRepository, RetryingLoggingCachedRepository… A decorator implements the same interface as the object it wraps, adds one behavior, and delegates to the inner object, so behaviors compose at runtime:
val repo: ProductRepository = Logging(Caching(Retrying(ApiProductRepository(api))))
Use decorators when:
- several cross-cutting behaviors should combine freely (cache, log, retry, metrics, auth),
- you can’t or shouldn’t modify the original (third-party, final, shared),
- you want each behavior testable alone with a fake inner object.
Things to get right:
- Order is semantics: a cache outside a logger hides hits from the log; a retry outside a timeout gives each attempt its own timeout.
- Every method is wrapped: a generic retry around an interface that includes
charge()can double-charge on timeouts. - Composition over inheritance: subclassing a concrete class to “decorate” it breaks when the base class calls its own overridable methods (
HashSet.addAllcallingadd), the fragile base class problem. Kotlin’sbydelegation avoids it but forwards un-overridden methods straight to the inner object.
What they're looking for
The combinatorial-subclass problem, how decorators delegate and compose, real examples (Java I/O, OkHttp interceptors, SwiftUI modifiers, Spring proxies), and the subtleties of ordering and of non-idempotent operations. Bonus for the fragile base class problem.
Common mistakes
- Describing a decorator that doesn't share the wrapped object's interface.
- Ignoring that wrapper order changes behavior.
- Wrapping a payment API in a generic retry decorator.
20What are the SOLID principles? Give an example of one you've applied.
Design patternsJunior
What are the SOLID principles? Give an example of one you've applied.
Design patternsJunior
In shortFive design rules for code that's easy to change. Single responsibility, open/closed, Liskov substitution, interface segregation and dependency inversion, each best explained with a concrete before and after.
SOLID is five rules of thumb for keeping code changeable:
| Principle | In one line | |
|---|---|---|
| S | Single responsibility | A class has one reason to change: one group of people asks for its changes. |
| O | Open/closed | Add new behavior by adding code (a new rule, a new class), not by editing code that works. |
| L | Liskov substitution | A subtype must work everywhere its parent is expected, with no surprises. |
| I | Interface segregation | Small interfaces shaped by what each client needs. |
| D | Dependency inversion | Business code defines the interfaces it needs; databases and vendors implement them. |
An example worth giving: a checkout class created its own payment client, so tests hit the real provider. Introducing a PaymentGateway interface owned by checkout, passed in through the constructor, fixed testing (D) and later made adding a second provider a new class instead of an edit (O).
Close with judgment: the principles pay off where code actually changes. Abstracting a closed, stable set of cases (three order states) adds indirection without benefit.
What they're looking for
All five named correctly with a one-line meaning each, and at least one real example from the candidate's own code. Stronger answers also say when a principle would be overkill.
Common mistakes
- Reciting the five names without an example.
- Defining single responsibility as "one method per class".
- Confusing dependency inversion with dependency injection.
- Claiming every class should follow all five, always.
21What's the difference between the State and Strategy patterns?
Design patternsMid-level
What's the difference between the State and Strategy patterns?
Design patternsMid-level
In shortThey have the same structure, a context delegating to an interchangeable object, but different control. With Strategy, the client chooses the algorithm and it rarely changes; with State, the state objects move the context from one state to the next as events arrive.
Both patterns put behavior behind an interface and let a context delegate to the current implementation. The difference is who switches it, and why:
- Strategy: the client picks an algorithm (standard vs express shipping, a sorting rule) and passes it in. The strategies don’t know about each other, and the choice usually doesn’t change during the object’s life.
- State: the context’s behavior depends on its current state, and the states themselves decide the next one as events arrive (a document goes Draft → In review → Published; a player goes loading → playing → paused). Each state object handles every event, and actions not allowed in a state are refused there.
A quick test: if your “strategies” start returning or installing the next strategy, you’ve built a state machine. In modern code, many state machines are an enum or sealed type with one transition function (state, event) → next state, which is the State pattern without a class per state.
What they're looking for
The shared structure, who controls switching, and a concrete example of each (shipping cost strategies vs a document or player state machine).
Common mistakes
- Saying they're the same pattern.
- Describing State only as an enum without transition rules.
- Not mentioning that impossible combinations of flags are what State removes.
22What's the difference between MVVM and MVI, and when would you choose each?
Design patternsMid-level
What's the difference between MVVM and MVI, and when would you choose each?
Design patternsMid-level
In shortIn MVVM the view observes a ViewModel's state and calls its methods; in MVI the view sends intents, and a pure reducer turns the current single immutable state into the next one. MVI is stricter and easier to trace and test; MVVM has less ceremony.
MVVM: the ViewModel exposes observable state (often several properties, or one UI state object) and methods the view calls (onEmailChange, logIn). It has no reference to the view, so it survives screen recreation and is testable with fakes. Changes can happen in any method.
MVI: the screen has one immutable state. The view sends intents; a pure reducer computes (state, intent) → state; side effects (network, storage) run outside the reducer and report back as new intents. Every change goes through one function, in one direction, which makes bugs traceable (log the intents) and transitions trivially testable.
Choosing: MVVM for small and medium apps where speed of development matters; MVI (or TCA, Redux) when many people work on many screens, when you need consistent conventions and traceability, or when state transitions are complex. Either way, keep observable state immutable, and treat one-off events (navigation, toasts) separately from state, consumed by id or delivered as a non-replayed stream.
What they're looking for
How state and changes flow in each, the testability and traceability benefits of a reducer, the cost in boilerplate, and handling of one-off events.
Common mistakes
- Saying MVI is just MVVM with a different name.
- Ignoring one-off events like navigation and toasts.
- Choosing by fashion instead of team and app needs.
23How do you work out and explain the time and space complexity of your solution?
AlgorithmsJunior
How do you work out and explain the time and space complexity of your solution?
AlgorithmsJunior
In shortName the inputs, find the cost of each step including library calls, multiply nested loops and add sequential steps, keep the dominant term, and state time and space separately with the trade-off you chose.
A reliable way to do it, out loud:
- Name the inputs. “n is the number of orders, m is the number of products.” Different inputs get different letters.
- Cost of each step, including library calls:
containson a list is O(n),sortis O(n log n), a hash map lookup is O(1) on average, removing from the front of an array list is O(n). - Combine. Nested loops multiply, steps that follow each other add.
- Simplify. Drop constants and keep the dominant term: O(2n + n log n) is O(n log n).
- Space. Count extra memory: sets, maps, copies, and the recursion depth.
Then say it as one sentence with the trade-off: “This is O(n) time and O(n) space because of the hash set; the sort-based version would be O(n log n) time with less extra memory.”
If the interviewer pushes, distinguish worst case (a hash map degrading with collisions), average case, and amortized cost (dynamic-array appends are O(1) amortized because resizes are rare).
What they're looking for
A systematic method rather than a guess, awareness of hidden costs (contains on a list, sorting, string building), naming separate variables for separate inputs, and stating both time and space. Bonus for mentioning worst vs average vs amortized.
Common mistakes
- Saying "it's O(n)" for code with contains or indexOf inside a loop.
- Calling two different input sizes both "n".
- Forgetting space complexity, or the recursion stack.
24What's the difference between a stack and a queue, and when would you use each?
AlgorithmsJunior
What's the difference between a stack and a queue, and when would you use each?
AlgorithmsJunior
In shortA stack is last in, first out; a queue is first in, first out. Use a stack for undo, matching brackets and depth-first traversal, and a queue for processing in arrival order and breadth-first traversal.
- A stack is LIFO, last in, first out: push and pop at the top, like a pile of plates. Used for undo, the back button, matching brackets, evaluating expressions, and depth-first search. Function calls themselves live on the call stack, which is why very deep recursion can overflow.
- A queue is FIFO, first in, first out: add at the back, remove from the front, like a line at a shop. Used for tasks in arrival order, message processing, rate-limited work, and breadth-first search, which finds shortest paths in unweighted graphs.
Both have O(1) operations at their ends, but only with the right type:
| Stack | Queue | |
|---|---|---|
| Swift | Array (append, popLast) | Deque from swift-collections |
| Kotlin | ArrayDeque (addLast, removeLast) | ArrayDeque (addLast, removeFirst) |
| Java | ArrayDeque (push, pop) | ArrayDeque (offer, poll) |
A strong answer also mentions the monotonic stack for “next greater element” problems, and bounded queues for backpressure between producers and consumers.
What they're looking for
Correct definitions with everyday examples, O(1) operations at the ends, concrete use cases including DFS/BFS, and the right type in your language (ArrayDeque, Array, Deque).
Common mistakes
- Using an ArrayList's remove(0) or a Swift Array's removeFirst() as a queue, which is O(n).
- Recommending Java's legacy Stack class.
- Not connecting recursion to the call stack.
25How do you find the first occurrence of a value in a sorted array that has duplicates?
AlgorithmsMid-level
How do you find the first occurrence of a value in a sorted array that has duplicates?
AlgorithmsMid-level
In shortUse a lower-bound binary search for the first index where the element is greater than or equal to the target, then check that the element at that index equals the target. It's O(log n), even with many duplicates.
The trick is to not stop when you find the target. Search for the boundary instead: the first index whose element is >= target.
fun firstOccurrence(nums: IntArray, target: Int): Int {
var low = 0
var high = nums.size // half-open: the answer is in [low, high]
while (low < high) {
val mid = low + (high - low) / 2
if (nums[mid] < target) low = mid + 1 else high = mid
}
return if (low < nums.size && nums[low] == target) low else -1
}
Why it works: everything before low is smaller than the target, everything from high on is >= the target, and every branch shrinks the range, so the loop ends with low == high at the boundary. Then one check tells you whether the target exists.
The last occurrence is the first index with an element > target, minus one, and the count of the target is the difference between the two boundaries, both in O(log n).
Worth mentioning: Java’s Arrays.binarySearch and Kotlin’s binarySearch return any matching index when there are duplicates, and Swift’s standard library has no binary search, so in an interview you write the lower bound yourself.
What they're looking for
A lower-bound search that keeps narrowing after a match instead of stopping, a clear loop invariant, a range that always shrinks, an overflow-safe midpoint, and awareness that library binary searches return any matching index.
Common mistakes
- Finding any match and then scanning left, which is O(n) for many duplicates.
- low = mid in a low < high loop, which can loop forever.
- Relying on Arrays.binarySearch or Kotlin's binarySearch, which don't guarantee the first index.
26When would you use BFS instead of DFS, and vice versa?
AlgorithmsMid-level
When would you use BFS instead of DFS, and vice versa?
AlgorithmsMid-level
In shortUse BFS for shortest paths in unweighted graphs and anything level-by-level; use DFS to explore, count components, detect cycles, and combine results from subtrees. BFS uses memory proportional to the width, DFS to the depth.
BFS (a queue) explores by distance from the start: everything one step away, then two, and so on. Use it for:
- the shortest path in an unweighted graph or grid (fewest moves),
- anything per level (level order, the minimum depth of a tree),
- multi-source spreading (the nearest exit from every cell, rotting oranges).
DFS (recursion or an explicit stack) goes as deep as possible first. Use it for:
- exploring and counting connected components (islands),
- cycle detection and topological sort (with three states in directed graphs),
- tree problems where a node combines its children’s results (height, diameter, lowest common ancestor), and backtracking.
Space: BFS holds a frontier, O(width); DFS holds a path, O(depth). A balanced tree of a million nodes is about 20 deep but 500,000 wide at the bottom, so DFS is far lighter there; a long chain is the opposite, and a recursive DFS can overflow the stack, so use an explicit stack for big or untrusted inputs.
On graphs, both need a visited set; in BFS, mark nodes when they’re added to the queue. And BFS’s shortest path only counts edges: with weights, use Dijkstra.
What they're looking for
Correct use cases for each, the data structures (queue vs stack/recursion), the space trade-off (width vs depth), the visited-set rule for graphs, and awareness of recursion limits.
Common mistakes
- Claiming DFS finds shortest paths in unweighted graphs.
- Forgetting the visited set on graphs with cycles.
- Ignoring stack depth for recursive DFS on large inputs.
27How do you approach a dynamic programming problem?
AlgorithmsMid-level
How do you approach a dynamic programming problem?
AlgorithmsMid-level
In shortCheck for overlapping subproblems, then define the state, the transition from the last decision, the base cases and the evaluation order; implement top-down with memoization or bottom-up with a table, and optimize space once it works.
A recipe that works for most interview DP problems:
- Recognize it: the brute force is recursive and solves the same subproblems repeatedly (count paths, minimum cost, maximum value, number of ways).
- State: the smallest description of a subproblem the rest depends on, like “the fewest coins for amount
a” or “the edit distance between the firstiandjcharacters”. - Transition: ask what the last decision was. “The last coin was c” gives
fewest(a) = 1 + min over c of fewest(a − c). - Base cases: the trivially known states (amount 0, empty strings).
- Order and answer: compute states after their dependencies; say which state is the answer.
Then implement it top-down (the recursion plus a shared memo, natural to write, visits only reachable states) or bottom-up (a table filled in order, no recursion depth issues, easy to space-optimize). Complexity is the number of states times the work per transition.
Say the trade-offs: rolling arrays reduce space (often O(n·m) to O(m)) but make reconstructing the actual choices harder; sentinels like Int.MAX_VALUE overflow when you add to them; and greedy is tempting but needs a proof, while DP doesn’t.
What they're looking for
A repeatable method rather than recalled solutions, the ability to derive a recurrence on an example, complexity as states times transition cost, and practical concerns like space optimization and overflow.
Common mistakes
- Jumping to code without defining the state.
- Using greedy without checking a counterexample.
- Memoization caches that aren't shared, or recursion too deep for the input size.
28When does a greedy algorithm work, and when do you need dynamic programming instead?
AlgorithmsMid-level
When does a greedy algorithm work, and when do you need dynamic programming instead?
AlgorithmsMid-level
In shortGreedy works when a locally best choice can never block a better overall result, which you show with an exchange argument. When an early choice can rule out a better combination, as with coins [1, 3, 4], you need DP.
A greedy algorithm makes the best-looking choice at each step and never revisits it. It’s correct when two things hold:
- Greedy-choice property: some optimal solution starts with the greedy choice.
- Optimal substructure: after that choice, the rest is the same kind of problem, solved by the same rule.
You justify it with an exchange argument: take any optimal solution, swap its first choice for the greedy one, and show it doesn’t get worse. Interval scheduling (keep the meeting that ends earliest) is the classic example.
Greedy fails when an early choice can block a better combination. Coins [1, 3, 4] and amount 6: biggest-first takes 4 + 1 + 1 (3 coins), but 3 + 3 uses 2. 0/1 knapsack fails the same way. Those problems need dynamic programming, which considers every choice and reuses the answers to smaller subproblems.
In practice: state the greedy rule, try two or three tiny counterexamples, and only commit to greedy if you can explain why swapping never helps.
What they're looking for
The greedy-choice property and optimal substructure in plain words, one proof technique (exchange argument or greedy stays ahead), and a concrete counterexample where greedy fails.
Common mistakes
- Saying greedy is "faster DP" without explaining when it's correct.
- Not being able to give a counterexample.
- Claiming coin change is always greedy.
29How do you merge overlapping intervals, and what are the edge cases?
AlgorithmsJunior
How do you merge overlapping intervals, and what are the edge cases?
AlgorithmsJunior
In shortSort by start, then walk once, comparing each interval with the last merged one; if it overlaps, extend the end to the larger end, otherwise start a new interval. O(n log n).
- Sort by start. After that, an interval can only overlap the last one you’ve merged.
- Walk once. If the current interval starts at or before the last merged end, they overlap: set the end to
max(last end, current end). Otherwise, append it as a new interval.
fun merge(intervals: Array<IntArray>): List<IntArray> {
val merged = mutableListOf<IntArray>()
for (cur in intervals.sortedBy { it[0] }) {
val last = merged.lastOrNull()
if (last != null && cur[0] <= last[1]) last[1] = maxOf(last[1], cur[1])
else merged.add(cur.copyOf())
}
return merged
}
Edge cases worth naming: nested intervals ([1, 10] and [2, 3], the reason for max), touching intervals ([1, 4] and [4, 5]: merged if closed, separate if half-open; ask), an empty input, and comparator overflow on the JVM if you sort with a[0] - b[0]. Sorting is O(n log n), the walk O(n).
What they're looking for
Sorting by start, comparing only with the last merged interval, using max for the end, and asking whether touching intervals count as overlapping.
Common mistakes
- Setting the end to the current interval's end instead of the maximum (breaks on nested intervals).
- Comparing every pair, O(n²).
- Not clarifying closed versus half-open intervals.
30How would you design a rate limiter for an API?
System designMid-level
How would you design a rate limiter for an API?
System designMid-level
In shortClarify who is limited and how strictly, pick an algorithm (usually a token bucket or sliding window counter), keep counters in a shared store like Redis updated atomically, return 429 with Retry-After, and decide what happens when the limiter fails.
- Clarify: who is limited (user, API key, IP, tenant), which endpoints, the limits, whether bursts are OK, the scale, and the latency budget.
- Algorithm: a token bucket (bursts up to the bucket size, then a steady rate) or a sliding window counter (two counters per key, close to exact). Mention the fixed window’s 2× burst at boundaries and the sliding log’s memory cost.
- Where: in the API gateway, before traffic reaches services, with shared counters in Redis so limits hold across all instances. Update them atomically with a Lua script, with expiry in the same step.
- Response:
429 Too Many RequestswithRetry-AfterandRateLimit-*headers; clients back off with jitter. - Failure: short timeouts to Redis; fail open for most endpoints, fail closed for abuse-sensitive ones like login.
At scale, trade exactness for latency: gateways lease tokens in batches for hot keys, and regions enforce shares of a global limit instead of coordinating on every request.
What they're looking for
Clarifying questions, at least two algorithms with trade-offs, shared atomic counters across instances, the HTTP contract, and failure policy. Senior answers add scale trade-offs (batching, regions) and per-endpoint fail-open/closed decisions.
Common mistakes
- Per-server in-memory counters behind a load balancer.
- Separate INCR and EXPIRE calls, or ignoring atomicity entirely.
- Forgetting what the client sees and how it should back off.
31How would you make a mobile app work offline and sync reliably?
System designSenior
How would you make a mobile app work offline and sync reliably?
System designSenior
In shortMake the local database the UI's source of truth, record writes in an outbox, push them idempotently with client-generated ids, pull changes since a server-issued cursor including deletions, and resolve conflicts with versions rather than device clocks.
- Reads and writes go to the local database (Room, SwiftData). The UI observes it, so it works offline and updates instantly.
- Writes are also recorded in an outbox in the same local transaction. A sync worker pushes it when there’s a network, in order, with client-generated UUIDs and idempotent requests (
PUT /items/{id}or anIdempotency-Key), so retries after lost responses don’t duplicate. - Pulls use a server-issued, commit-ordered cursor:
GET /changes?cursor=…returns created, updated and deleted items (tombstones), and the new cursor is saved in the same transaction as the applied changes. - Conflicts are detected with versions (each write carries the version it was based on) and resolved per field: last write wins by server order for low-stakes fields, merges for independent fields, domain rules where they exist, CRDTs for collaborative text. Make outcomes visible to the user.
- Platform: WorkManager with network constraints and backoff on Android; sync on foreground plus
BGAppRefreshTaskon iOS; real-time channels (WebSocket, silent push) only trigger the normal pull.
Senior follow-ups: tombstone retention and full resyncs, migrating unsynced outbox entries across app versions, partial sync for large datasets, and monitoring outbox size and sync failures per app version.
What they're looking for
The local-first data flow, idempotency under retries, cursors and tombstones, a conflict strategy with concrete rules, and platform realities (WorkManager, iOS background tasks). Bonus for discussing per-field merges and when CRDTs are worth it.
Common mistakes
- Network-first screens with a cache as an afterthought.
- Server-generated ids with POST retries, causing duplicates.
- Timestamp cursors and device-clock last-write-wins.
32How do you approach a system design interview question?
System designJunior
How do you approach a system design interview question?
System designJunior
In shortClarify requirements and scope first, estimate the scale, sketch the API and data, draw a high-level design that handles the main flows, go deep on the hard parts, and finish with bottlenecks and trade-offs.
I use the same six steps for any question, and say them out loud so the interviewer can follow:
- Requirements (about 5 minutes). Functional: the two or three main features. Non-functional: scale, latency, availability, how fresh data must be. I write them down and agree on what’s out of scope.
- Estimates (about 5 minutes). Requests per second (per day ÷ 100,000, times 2 to 5 for peak), storage per year, read/write ratio, and what the numbers mean: read-heavy, so cache; terabytes of files, so object storage.
- API and data model. The main endpoints and records.
- High-level design. Load balancer, stateless servers, cache, database, queues where work can be delayed. I trace each main use case through it.
- Deep dives. The one to three parts that are hard for this problem, explained as trade-offs: what I gain, what I give up, and why it fits the requirements.
- Wrap-up. Bottlenecks at 10× scale, failure cases, what I’d monitor.
If the interviewer steers me somewhere, I follow; they’re showing me what they want to evaluate.
What they're looking for
A clear, repeatable structure that starts with requirements, numbers that lead to decisions, and an understanding that trade-offs and communication are what's being evaluated.
Common mistakes
- Starting with technologies before any requirements.
- Estimating numbers and never using them.
- Spending the whole interview on boxes without any deep dive.
- Long silences instead of thinking aloud.
33How would you scale a backend from one server to millions of users?
System designMid-level
How would you scale a backend from one server to millions of users?
System designMid-level
In shortMake the app servers stateless and run many behind a load balancer, then relieve the database step by step with indexes, caching, read replicas and queues, and partition data only when writes or size require it.
Scale in response to bottlenecks, roughly in this order:
- Separate the database from the app server, and add the obvious indexes.
- Stateless app servers behind a load balancer: sessions in Redis or tokens, files in object storage, scheduled jobs run once. Health checks (readiness) and graceful shutdown make deploys and failures invisible.
- Caching: a CDN for static and public content, Redis for hot reads (cache-aside with TTLs), HTTP caching headers for clients.
- Read replicas for read-heavy load, with read-your-writes handling for users who just changed something.
- Queues for slow or unreliable work (emails, images, third-party calls), with idempotent consumers.
- Partitioning (sharding) when writes or data size outgrow one primary, with a shard key that spreads load and keeps hot queries on one shard.
At every step, watch the next bottleneck: database connections (pool size × servers), write throughput, hot keys. Many products never need step 6.
What they're looking for
A sensible order of steps tied to bottlenecks, stateless servers, health checks, caching and replicas before sharding, and awareness of the database as the usual limit.
Common mistakes
- Jumping straight to microservices or sharding.
- Forgetting where sessions and files go.
- Ignoring connection limits and replica lag.
34A queue can deliver the same message twice. How do you make processing safe?
System designMid-level
A queue can deliver the same message twice. How do you make processing safe?
System designMid-level
In shortAcknowledge only after the work succeeds, and make the consumer idempotent, usually by recording each message id with a unique constraint in the same transaction as the side effect, so a duplicate is detected and skipped.
Queues are effectively at least once: a consumer can finish the work and crash before acknowledging, a Kafka rebalance can resume from an older offset, or a retry can race with a slow first attempt. So the consumer must make a second delivery harmless:
- Ack after the work, never before (acking first loses messages).
- Record processed ids: insert the message id (or a business key like
paymentId) into aprocessed_messagestable with a unique constraint, in the same transaction as the side effect. A duplicate fails the insert or finds the row and does nothing. - Prefer naturally idempotent operations where possible: “set status to paid” or an upsert by key instead of “add 10 EUR”.
- Publish reliably: the producer writes events to an outbox table in the same transaction as its own change, and a relay publishes them, so events are never lost and duplicates are handled by the idempotent consumer.
Kafka’s exactly-once semantics cover Kafka-to-Kafka processing; side effects in your own database still need this.
What they're looking for
Why duplicates happen (crash before ack, rebalances, retries), an idempotency mechanism tied to the same transaction, and the outbox pattern for publishing.
Common mistakes
- Claiming the broker guarantees exactly-once delivery to your database.
- Checking for duplicates in a separate step from the side effect.
- Acknowledging before processing.
No questions match these filters.