Zalando interview prep: search engineering topics 83 questions in 5 chapters

  1. 01Java and the JVM25 questions
  2. 02Search fundamentals and Elasticsearch22 questions
  3. 03Search system design28 questions
  4. 04Coding8 questions
  5. 05Sources

Java and the JVM

Memory model, garbage collection, maps, Java 8 benefits and diagnosing memory growth in a JVM service.

Search backends spend their time in concurrent request threads, shared maps and a garbage-collected heap. This chapter reads in that order: the memory model and pooling, garbage collection and retained memory, maps, the Java 8 APIs that still come up, and how you show a service meets its performance requirements.

1.1 What is the Java Memory Model, and what does happens-before guarantee?

A search node is a concurrent JVM service, and happens-before is how you reason about visibility of shared request and index state.

The 30 second answer

The memory model defines when one thread's writes become visible to another. Without a happens-before edge, the compiler and CPU may reorder or cache values, so a thread can see stale or half-published data. Edges come from program order, unlocking then locking the same monitor, a volatile write then a read of it, starting and joining threads, and final fields set in a constructor.

The problem it solves. Each thread may run on a core with its own cache, and the JIT and CPU reorder instructions when a single thread cannot tell the difference. Another thread can. So "I wrote the field before setting the flag" does not mean another thread sees them in that order. The Java Language Specification, chapter 17, defines which orderings a correct program can rely on.

Happens-before. The JLS says that if one action happens-before another, the first is visible to and ordered before the second. The edges you use every day:

  • Program order: within one thread, each action happens-before the next.
  • Monitor lock: an unlock of a monitor happens-before every later lock of the same monitor.
  • Volatile: a write to a volatile field happens-before every later read of that field.
  • Thread start and join: start() happens-before the started thread's actions; all of a thread's actions happen-before another thread returns from join() on it.
  • Transitivity: chain them. If A happens-before B and B happens-before C, A happens-before C.

Separately, final fields set in a constructor are seen correctly by other threads once the constructor finishes, provided this did not escape during construction.

Three properties to keep apart. Visibility (does the other thread see the write), ordering (in what order), and atomicity (is a compound action indivisible). volatile gives the first two, not the third: count++ on a volatile field still loses updates. That difference is covered in volatile and AtomicInteger.

Safe publication. The plain field below is published through the volatile flag: the write to data happens-before the volatile write, which happens-before the reader's volatile read.

public class Visibility {
    private static volatile boolean running = true;       // remove volatile and the loop may never stop
    private static int data;                              // plain field, published by the volatile write

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) { Thread.onSpinWait(); }
            System.out.println("saw data=" + data);         // guaranteed 42: write data hb volatile write hb volatile read
        });
        worker.start();
        Thread.sleep(50);
        data = 42;
        running = false;
        worker.join();
    }
}

Verified with OpenJDK 21.0.13: it prints saw data=42. Without volatile, the loop is allowed never to see the flag change.

Data race. Two accesses to the same variable, at least one a write, not ordered by happens-before. A program with no data races behaves as if sequentially consistent; that is the guarantee to aim for.

Alternate interview scenarios

"A double-checked locking singleton works on your laptop but sometimes hands out a half-built object. Why?"

Without volatile on the instance field, the write of the reference can become visible before the constructor's writes, so another thread sees a non-null reference to an object whose fields are not yet set. Declaring the field volatile adds the happens-before edge. Simpler still: a holder class or an enum, whose initialisation the JVM already makes thread-safe.

  1. JLS 21 chapter 17: Threads and Locks (memory model, happens-before, final fields)docs.oracle.com
  2. Zalando Engineering: Interview preparation tips for Java developers (June 2016)engineering.zalando.com
  3. LeetCode Discuss: Zalando C6 Backend Engineer, Berlin (candidate report, May 2022)leetcode.com

Canonical answer

1.2 How do you size and configure a thread pool for a backend service?

Query and indexing work share executor pools; an undersized or unbounded pool shows up as latency or memory pressure.

The 30 second answer

I use a bounded ThreadPoolExecutor rather than unbounded factories. I pick core and maximum sizes from the work: near the core count for CPU-bound tasks, larger for blocking I/O, then confirm by measuring. A bounded queue gives backpressure, and a rejection policy says what happens when it is full. I shut it down in order and make tasks respond to interruption.

How ThreadPoolExecutor grows. Per the Javadoc: if fewer than corePoolSize threads are running, a new task gets a new thread. Otherwise the task is queued. Only if the queue is full does the pool add threads up to maximumPoolSize. If both are full, the RejectedExecutionHandler runs. Consequence: with an unbounded queue, the pool never grows past the core size, and work piles up in memory.

Sizing. For CPU-bound work, start near the number of cores. For work that waits on I/O, more threads help, roughly in proportion to how long each task waits versus computes. Treat any formula as a starting point and measure throughput, latency and queue depth under load. Separate pools for unrelated work, such as calls to two different downstreams, stop one slow dependency from starving the other.

Avoid unbounded defaults. Executors.newCachedThreadPool() is documented as an unbounded thread pool, and newFixedThreadPool uses an unbounded queue. Both can turn a traffic spike into thousands of threads or a huge queue.

Rejection policies. AbortPolicy, the default, throws RejectedExecutionException; CallerRunsPolicy runs the task in the submitting thread, which slows producers down; DiscardPolicy and DiscardOldestPolicy drop work and are only acceptable when losing tasks is fine.

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

public class PoolConfig {
    public static void main(String[] args) throws InterruptedException {
        int cores = Runtime.getRuntime().availableProcessors();
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
            cores, cores * 4,                               // core and max threads (tune from measurements)
            30, TimeUnit.SECONDS,                           // idle threads above core size time out
            new ArrayBlockingQueue<>(100),                  // bounded queue: backpressure instead of unbounded memory
            new ThreadPoolExecutor.CallerRunsPolicy());     // saturated: the caller runs the task and slows down
        AtomicInteger done = new AtomicInteger();
        for (int i = 0; i < 500; i++) pool.execute(() -> {
            try { Thread.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            done.incrementAndGet();
        });
        pool.shutdown();                                    // stop accepting, finish queued work
        if (!pool.awaitTermination(10, TimeUnit.SECONDS)) pool.shutdownNow(); // interrupt stragglers
        System.out.println("done=" + done.get());          // 500: nothing was dropped
    }
}

Verified with OpenJDK 21.0.13: it prints done=500, because CallerRunsPolicy slowed the producer instead of dropping tasks.

Shutdown. shutdown() stops new submissions and lets queued work finish; awaitTermination waits; shutdownNow() interrupts what is still running. Tasks must check interruption or they never stop.

Virtual threads. For many concurrent blocking calls, Java 21 virtual threads remove much of the sizing problem, but you still need limits on downstream concurrency, for example a semaphore per dependency. See virtual threads.

Alternate interview scenarios

"Runnable or Callable: which do you submit to a pool?"

Callable is "similar to Runnable", as its Javadoc puts it, but returns a result and can throw a checked exception; Runnable does neither. submit accepts both and returns a Future, and execute accepts only Runnable. In both cases an exception is captured by the Future when you use submit, so someone must call get or it is lost; see An async task throws, but nothing is logged.

  1. ThreadPoolExecutor Javadoc (Java 21)docs.oracle.com
  2. Executors Javadoc (Java 21)docs.oracle.com
  3. Zalando Engineering: Interview preparation tips for Java developers (June 2016)engineering.zalando.com
  4. Callable Javadoc (Java 21)docs.oracle.com

Canonical answer

1.3 When do ReadWriteLock and BlockingQueue help?

Catalog updates versus many query readers is the classic readers-writers and hand-off pattern in a search backend.

The 30 second answer

A read-write lock lets many readers proceed together while a writer gets exclusive access, so it helps when reads vastly outnumber writes and each read takes a while. Otherwise a plain lock is simpler and often faster. A blocking queue is the standard producer-consumer handoff: producers put, consumers take, and a bounded capacity gives you backpressure.

ReadWriteLock. ReentrantReadWriteLock holds a pair of locks: many threads may hold the read lock at once, the write lock is exclusive. It pays off for a structure read constantly and rebuilt rarely, such as an in-memory routing table or a configuration snapshot, where readers do real work under the lock. Costs and caveats from the Javadoc and practice:

  • It does not impose reader or writer preference by default; in non-fair mode a continuously contended lock can postpone some threads indefinitely. A fair mode exists at a throughput cost.
  • A thread holding the read lock cannot upgrade to the write lock; trying deadlocks. Downgrading, acquiring the read lock before releasing the write lock, is allowed.
  • If writes are frequent or critical sections tiny, the extra bookkeeping makes it slower than a single ReentrantLock.

Alternatives: an immutable snapshot swapped through a volatile or AtomicReference field gives readers no locking at all; StampedLock offers optimistic reads.

BlockingQueue. The Javadoc says implementations are designed primarily for producer-consumer queues. put blocks when a bounded queue is full and take blocks when it is empty, so a slow consumer slows producers instead of exhausting memory. offer and poll with timeouts avoid blocking forever. A BlockingQueue does not accept null, because null signals failure from poll. Choose ArrayBlockingQueue for a fixed bound, LinkedBlockingQueue with an explicit capacity, or SynchronousQueue for direct handoff.

Example use. An indexing worker that reads change events, batches them, and sends bulk requests: producers put events on a bounded queue, one or more consumers drain up to N events or wait up to T milliseconds, then send a batch. When the search cluster slows down, the queue fills and the producers slow down too.

  1. ReentrantReadWriteLock Javadoc (Java 21)docs.oracle.com
  2. BlockingQueue Javadoc (Java 21)docs.oracle.com
  3. Zalando Engineering: Interview preparation tips for Java developers (June 2016)engineering.zalando.com

Canonical answer

1.4 What is the difference between volatile and AtomicInteger?

Hot-path counters and flags need a clear visibility and atomicity story, not unsynchronized writes.

The 30 second answer

Volatile only guarantees visibility and ordering: every thread sees the latest write to that variable. But count plus plus is a read, an add and a write, so two threads can still lose updates with volatile. AtomicInteger makes the whole increment atomic using compare-and-set, without a lock. So I use volatile for a simple stop flag and atomics for counters; under heavy contention, LongAdder.

volatile boolean running = true;     // fine: one writer, others only read
volatile int count;  count++;        // broken: lost updates under contention

AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet();
hits.updateAndGet(x -> Math.min(x + 1, 100));
hits.compareAndSet(expected, newValue);

A write to a volatile field happens-before every later read of that field, so it also safely publishes the writes made before it. It does not make compound operations atomic.

Atomic classes are built on compare-and-set (CAS): read the value, compute the new one, and swap only if nobody changed it meanwhile; otherwise retry.

Under heavy contention many threads keep retrying the same CAS. LongAdder spreads updates across cells and sums them on read; its Javadoc says it has higher throughput under high contention at the cost of more space. Its sum() is not an atomic snapshot, so use it for statistics, not IDs or sequence numbers.

Alternate interview scenarios

"Two threads update different fields of the same object, and results are occasionally inconsistent. What are the Java Memory Model implications?"

First, a correction to the premise: writes to different fields do not interfere with each other at the memory level. JLS §17.6 rules out word tearing, so each field is updated independently. JLS §17.7 lets a non-volatile long or double be written as two 32-bit halves, so a reader can see a torn value. Making the field volatile prevents that. So the real causes are usually one of three things. First, an invariant spans both fields, for example min <= max, and a reader sees one updated field without the other because the two writes are not done together under one lock. Second, a reader has no happens-before edge, such as volatile, synchronized or a final field, so it may see stale or reordered values. Third, the object was published unsafely before its constructor finished. The fix is to guard both fields with the same lock, or to replace the object with an immutable one through a volatile or AtomicReference. I prove it with a concurrency stress test, for example jcstress, rather than by reading the code.

"Explain double-checked locking in a Singleton. Why do you need volatile?"

The pattern checks the field, locks only if it's null, and checks again inside the lock. Without volatile it's broken: instance = new Singleton() is an allocation, a constructor run and a reference write, and those writes can be reordered. Another thread can then see a non-null reference to an object whose fields aren't initialized yet. Since Java 5, a volatile write cannot be reordered with earlier writes, and it happens-before the later read, so the second thread sees a fully built object. In practice I'd rather avoid the pattern: an enum singleton, or a static holder class that the JVM initializes lazily and thread-safely, gives the same laziness without any locking code.

private static volatile Singleton instance;
static Singleton get() {
    Singleton s = instance;
    if (s == null) {
        synchronized (Singleton.class) {
            s = instance;
            if (s == null) instance = s = new Singleton();
        }
    }
    return s;
}
  1. JLS 8.3.1.4 volatile Fieldsdocs.oracle.com
  2. JLS 17.4.5 Happens-before Orderdocs.oracle.com
  3. AtomicInteger Javadocdocs.oracle.com
  4. LongAdder Javadocdocs.oracle.com
  5. JLS 21 §17.4 Memory model, §17.6 Word tearing, §17.7 Non-atomic double and longdocs.oracle.com
  6. Bacon, Pugh et al.: The "Double-Checked Locking is Broken" Declarationcs.umd.edu
  7. JLS 21 §12.4.2 Detailed initialization proceduredocs.oracle.com

Canonical answer

1.5 What is the difference between synchronized and ReentrantLock?

Shared caches and filters on a search JVM need an explicit locking policy under concurrent queries.

The 30 second answer

Both give mutual exclusion and the same memory visibility guarantees. Synchronized is simpler and always releases the lock when the block ends, even on an exception. ReentrantLock gives more control: try to lock with a timeout, wait in a way that can be interrupted, optional fairness, and multiple conditions. The cost is that I must unlock in a finally block myself.

private final ReentrantLock lock = new ReentrantLock();

void transfer() throws InterruptedException {
    if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
        try { /* critical section */ }
        finally { lock.unlock(); }
    } else {
        // back off instead of waiting forever
    }
}
synchronizedReentrantLock
Releaseautomaticmanual, in finally
Try / timeoutnotryLock(), tryLock(timeout)
Interruptible waitnolockInterruptibly()
Fairnessnonew ReentrantLock(true)
Conditionsone wait set (wait / notify)many Conditions (await / signal)

Both are reentrant: the owning thread can acquire the lock again. [Opinion] Default to synchronized; use ReentrantLock when you need its extra features. On Java 21 with virtual threads, also prefer ReentrantLock around long blocking calls to avoid pinning (fixed for synchronized in Java 24 by JEP 491).

Alternate interview scenarios

"Your code uses synchronized everywhere and the application is slow. What's wrong?"

Switching to ReentrantLock would not fix it; the problem is contention. If every request goes through the same lock, requests run one at a time on that path, however many cores you have. I confirm it with a few thread dumps: many threads BLOCKED waiting to lock the same monitor. A JFR recording shows the same thing as monitor-enter events with long durations. Then I shrink the critical sections, so I/O and remote calls happen outside the lock. I swap synchronized maps and counters for ConcurrentHashMap, atomics or LongAdder. I make shared objects immutable, or confine state to one thread, so they need no lock at all. Lock splitting, one lock per account instead of one for the bank, also helps. And on Java 21 with virtual threads, blocking inside synchronized pins the carrier thread, which makes it worse until Java 24.

  1. ReentrantLock Javadocdocs.oracle.com
  2. Lock Javadoc (memory synchronization)docs.oracle.com
  3. JLS 17.1 Synchronizationdocs.oracle.com
  4. JEP 444: Virtual Threadsopenjdk.org
  5. jcmd (Thread.print and JFR.start), Java 21docs.oracle.com
  6. java.util.concurrent package summary (Java 21)docs.oracle.com

Canonical answer

1.6 What are virtual threads?

High fan-out to Elasticsearch or downstream services makes blocking-thread cost part of the capacity plan.

The 30 second answer

Virtual threads, final in Java 21, are lightweight threads managed by the JDK instead of the operating system. They are still normal Thread objects, so ordinary blocking code works. When a virtual thread waits on I/O, the JDK unmounts it from the real OS thread underneath, and that OS thread runs another virtual thread. So I can have one thread per request, even with a huge number of requests.

Virtual threads were previewed in Java 19 and 20 and became final in Java 21 (JEP 444).

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var req : requests) executor.submit(() -> handle(req));
}   // close() waits for submitted tasks

Thread.ofVirtual().name("worker").start(() -> callApi());
Thread.startVirtualThread(() -> callApi());

A virtual thread runs on a carrier platform thread from a dedicated ForkJoinPool scheduler. When it blocks on most JDK I/O, sleep, or java.util.concurrent locks and queues, its stack is saved to the heap and the carrier is freed. When the operation completes, it is scheduled again, possibly on another carrier.

Virtual threads are always daemon threads, and they are meant to be created per task, never pooled.

  1. JEP 444: Virtual Threadsopenjdk.org
  2. Oracle guide: Virtual Threadsdocs.oracle.com

Canonical answer

1.7 How does garbage collection work in the JVM?

Stop-the-world pauses on a search node cut straight into tail latency.

The 30 second answer

The garbage collector finds objects that can no longer be reached from roots, like thread stacks and static fields, and frees their memory. Most HotSpot collectors are generational, because most objects die young: the young area is collected often and cheaply, and objects that survive long enough move to the old area. Collectors trade throughput, pause time and memory. G1 is the default on most server machines.

Reachability: GC roots include local variables in active stack frames, static fields, JNI references and live threads. Anything not reachable from a root is garbage, so circular references alone don't leak.

Generations: new objects are allocated in the young generation (eden). A young collection copies live objects to a survivor space; objects that survive several collections are promoted to the old generation. Old-generation collection is rarer and more expensive.

HotSpot collectors in Java 21:

CollectorGoal
Serialsmall heaps, single CPU
Parallelmaximum throughput, longer pauses
G1 (default)balance throughput and a pause-time target
ZGCvery low pauses, mostly concurrent
Shenandoah (in many OpenJDK builds)low pauses, concurrent compaction

G1 became the default on server-class machines in Java 9 (JEP 248); on machines the JVM considers small it picks Serial.

  1. GC Tuning Guide: Garbage Collector Implementationdocs.oracle.com
  2. GC Tuning Guide: Available Collectorsdocs.oracle.com
  3. JEP 248: Make G1 the Default Garbage Collectoropenjdk.org

Canonical answer

1.8 How do G1 and ZGC compare?

Heap size and pause goals decide whether a search JVM can keep p99 stable under a large working set.

The 30 second answer

G1, the default, splits the heap into regions and tries to meet a pause-time goal, two hundred milliseconds by default, while keeping good throughput. ZGC does almost all its work while the application runs, so pauses stay very short even on huge heaps, at the cost of some CPU and memory overhead. I'd keep G1 for general services and pick ZGC when tail latency really matters.

G1ZGC
Defaultyesno, -XX:+UseZGC
Heap layoutequal-sized regions, young and oldregions of varying sizes; generational in 21 with a flag
Pausestargets -XX:MaxGCPauseMillis (default 200 ms)very short and mostly independent of heap size
Compactionduring evacuation pausesconcurrent
Best formost serviceslarge heaps, strict latency

Tuning: for G1, set the heap size and the pause goal, and avoid fixing the young-generation size, which overrides the pause-time logic. For ZGC, the most important setting is the max heap size (-Xmx), so the collector has enough headroom for the allocation rate.

[Opinion] ZGC usually costs some throughput and memory compared with G1. Measure p99 latency and CPU on your own workload before switching.

On Java 21 use -XX:+UseZGC -XX:+ZGenerational for generational ZGC (JEP 439); from Java 23 it is the default ZGC mode.

  1. GC Tuning Guide: G1docs.oracle.com
  2. GC Tuning Guide: ZGCdocs.oracle.com
  3. JEP 439: Generational ZGCopenjdk.org

Canonical answer

1.9 What are the JVM memory areas?

Heap, metaspace and native or direct memory all show up when an Elasticsearch client or off-heap cache grows.

The 30 second answer

Each thread has its own program counter, its own JVM stack with a frame per method call holding local variables, and a native method stack. All threads share the heap, where every object and array lives and which the garbage collector manages, and the method area, which holds class data and code. In HotSpot since Java 8, class metadata lives in native memory called Metaspace, not in the heap.

From JVMS 2.5:

AreaScopeHoldsError
pc registerper threadcurrent instructionnone
JVM stackper threadframes: locals, operand stackStackOverflowError, or OutOfMemoryError if it can't grow
Native method stackper threadnative callssame as JVM stack
Heapsharedobjects and arraysOutOfMemoryError: Java heap space
Method areasharedclass structures, run-time constant pool, method codeOutOfMemoryError (HotSpot: Metaspace)

HotSpot specifics: Java 8 removed PermGen and moved class metadata to Metaspace in native memory, limited by -XX:MaxMetaspaceSize (unlimited by default). Interned strings and static field values are on the heap. The process also uses native memory for thread stacks, the code cache, direct ByteBuffers and GC data structures; -XX:NativeMemoryTracking=summary with jcmd <pid> VM.native_memory summary shows the breakdown.

  1. JVMS 2.5 Run-Time Data Areasdocs.oracle.com
  2. GC Tuning Guide: Other Considerations (Metaspace)docs.oracle.com
  3. Troubleshooting Guide: Diagnostic Tools (Native Memory Tracking)docs.oracle.com

Canonical answer

1.10 How do you investigate frequent GC pauses?

Rising query latency often starts as an allocation or pause investigation, not as a query-DSL rewrite.

The 30 second answer

I turn on GC logging and look at how often pauses happen, how long they are, whether they're young or full collections, and how much heap is still used after each one. Frequent young pauses usually mean a high allocation rate. Old-generation usage that keeps climbing after every collection suggests a leak. Full collections in G1 usually mean the heap is too small. Then I reduce allocation, resize, or consider ZGC.

1. Get data:

-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m

2. Read the pattern:

SymptomLikely causeAction
Very frequent young pauseshigh allocation rate or small young genreduce allocation (JFR allocation profiling), give more heap
Long young pausesmany surviving objects to copyfewer long-lived temporary objects, check promotion
Old gen after GC keeps risingmemory leakheap dump and class histogram
G1 full GCs or evacuation failuresheap too small, or marking starts too latemore heap, lower -XX:InitiatingHeapOccupancyPercent
Humongous allocations in G1objects of at least half a regionlarger -XX:G1HeapRegionSize, or avoid huge arrays

3. Tune carefully: set -Xms equal to -Xmx for steady services, adjust -XX:MaxGCPauseMillis, and avoid fixing the young-generation size with G1. If pause time is the main problem on a large heap, test ZGC.

[Opinion] Most GC problems are solved by allocating less, not by tuning flags.

  1. GC Tuning Guide: G1 tuningdocs.oracle.com
  2. java command: -Xlogdocs.oracle.com
  3. Troubleshooting Guidedocs.oracle.com

Canonical answer

1.11 How do ThreadLocal, static collections and lingering references cause memory retention?

Per-request buffers and static caches are a common way a search JVM retains memory after traffic drops.

The 30 second answer

A ThreadLocal value lives as long as its thread, and pool threads never die, so a value I forget to remove stays forever and can even leak into the next request. Static maps and caches without eviction grow without limit, and listeners that are never unregistered keep objects alive. I confirm it with a heap dump and look at what holds the biggest retained size.

Thread-local values live as long as the thread does. In a thread pool, threads never die, so a value you forget to remove() stays forever and can leak to the next request. Static maps and caches without eviction grow without limit, and listeners that are never unregistered keep objects alive. I confirm with a heap dump (jcmd <pid> GC.heap_dump) and look at what holds the biggest retained size. Follow-up: How do you use ThreadLocal safely? (try { tl.set(x); ... } finally { tl.remove(); }, or scoped values in newer Java.) Alternate interview scenarios

"Your app ran fine for 30 days, then crashed with OutOfMemoryError. No code changes. What happened?" Thirty days of stable running points to slow growth, not a sudden spike. My hypotheses: a slow leak, such as ThreadLocals in pool threads, a cache or map that never evicts, or listeners that are never removed; data growth, where a query or in-memory list that was small a month ago now loads far more rows; or a rarer path, such as a monthly job or a new kind of input. First I read the error message, because heap space, metaspace and direct buffer memory have different causes. Then I compare heap histograms taken some time apart to see which class keeps growing, and open the heap dump from -XX:+HeapDumpOnOutOfMemoryError to find what is holding those objects. The fix is to remove the reference or bound the structure. A restart only resets the clock.

"Your Spring Boot service works in development but crashes every night at 2 AM in production. How do you debug it?" A fixed time points to something scheduled, so I first list what runs then: batch jobs, @Scheduled tasks, cron jobs, backups, log rotation or a database maintenance window. Next I find out what "crash" means, because the fixes differ. An OutOfMemoryError in the JVM log is a heap or metaspace problem. A container killed by the kernel or the orchestrator for exceeding its memory limit, with no Java error, means the process total, including native memory, is too large for the limit. A pod restarted by a failing liveness probe means the app was alive but slow, often because every thread waited. Then I use the evidence. Heap after GC rising steadily over the run suggests retention: an unbounded static cache, ThreadLocals in pool threads, or a job that loads everything into memory. GC logs with -Xlog:gc, a heap dump from -XX:+HeapDumpOnOutOfMemoryError and class histograms show what is held. If the job keeps database connections, HikariCP's pool, whose maximum size defaults to 10, runs out, and other requests wait up to connectionTimeout and then fail with an SQLException. That is a timeout, not a crash. leakDetectionThreshold logs where a connection was borrowed but not returned, and try-with-resources fixes it. Finally I alert on heap after GC, pool pending threads and job duration before the failure, and I reproduce the job's data volume outside production.

  1. ThreadLocal Javadocdocs.oracle.com
  2. Troubleshooting memory leaks (Java 21)docs.oracle.com
  3. jcmddocs.oracle.com
  4. Java 21 java command: -XX:+HeapDumpOnOutOfMemoryError and -Xlog:gcdocs.oracle.com
  5. HikariCP README: maximumPoolSize, connectionTimeout and leakDetectionThresholdgithub.com

Canonical answer

1.12 How do JVM class loaders work, and what do ClassNotFoundException and NoClassDefFoundError tell you?

Dynamic clients and plugins fail in distinct ways; the error type tells you whether a class was missing at load or at use.

The 30 second answer

Class loaders find class bytes and define classes. Java has three built-in ones: bootstrap for the core platform, platform for the rest of the Java SE modules, and the application loader for the class path. Each loader asks its parent first, so core classes cannot be replaced. ClassNotFoundException means an explicit lookup failed; NoClassDefFoundError means a class that existed at compile time is missing at run time.

Loading, linking, initialising. The JVM specification splits a class's start into loading (find the bytes and create the class), linking (verify, prepare static fields, resolve references) and initialising (run static initialisers). Loading is done by a class loader, and a class's run-time identity is its name plus its defining loader, so the same class name loaded by two loaders gives two different types.

The built-in loaders. The ClassLoader Javadoc lists three:

  • Bootstrap: the virtual machine's built-in loader, "typically represented as null", with no parent. It loads core classes such as java.lang.String.
  • Platform: loads the remaining Java SE platform APIs and JDK classes.
  • System (application): loads classes from the application class path and module path.

Parent delegation. The Javadoc: a loader "will usually delegate the search for the class or resource to its parent class loader before attempting to find the class or resource itself." So an application cannot replace java.lang.String with its own copy.

public class Loaders {
    public static void main(String[] args) throws Exception {
        System.out.println("String     -> " + String.class.getClassLoader());          // bootstrap: null
        System.out.println("java.sql   -> " + java.sql.Connection.class.getClassLoader().getName());
        ClassLoader app = Loaders.class.getClassLoader();
        System.out.println("Loaders    -> " + app.getName() + ", parent " + app.getParent().getName());
        try {
            Class.forName("com.example.Missing");
        } catch (ClassNotFoundException e) {
            System.out.println("forName    -> " + e);
        }
    }
}

Verified with OpenJDK 21.0.13. It prints String -> null, java.sql -> platform, Loaders -> app, parent platform, and java.lang.ClassNotFoundException: com.example.Missing.

Reading the two errors.

  • **ClassNotFoundException** is a checked exception from an explicit lookup such as Class.forName or loadClass: the name is wrong, or the JAR is not on the path. Common with JDBC drivers and plugins.
  • **NoClassDefFoundError** is an error: the class existed when the calling code was compiled, but its definition "can no longer be found" at run time. The JVM specification says that when loading fails because the class cannot be found, it throws NoClassDefFoundError with the ClassNotFoundException as its cause. The other common cause is a static initialiser that threw: the first use fails with ExceptionInInitializerError, later uses with NoClassDefFoundError. Always read the cause.

Where it matters in production. Dependency conflicts (two versions of a library, the wrong one wins), application servers and plugin systems with a loader per application, and memory leaks when something holds a reference to an old loader so none of its classes can be unloaded after a redeploy. A Spring Boot fat JAR uses its own launcher class loader to load the nested JARs inside it; see fat JAR vs normal JAR.

  1. ClassLoader Javadoc (Java 21): delegation model and built-in class loadersdocs.oracle.com
  2. JVM Specification SE 21, chapter 5: Loading, Linking, and Initializingdocs.oracle.com
  3. NoClassDefFoundError Javadoc (Java 21)docs.oracle.com
  4. Spring Boot reference: The executable JAR formatdocs.spring.io

Canonical answer

1.13 A JVM search service uses more and more memory but shows no obvious leak. How do you investigate?

The 30 second answer

Before I call it a leak, I split the problem into three signals. The heap live set after full collections, the allocation rate, and the container's resident memory. A flat live set with growing resident memory points at native memory, so I turn on Native Memory Tracking. A rising live set means retained objects, so I use Flight Recorder's old object samples and compare heap dumps over time. I size from evidence, not by raising the heap.

"Memory keeps growing" is three different problems that look the same on a dashboard. A good answer separates them before touching any flag.

Signal one: the live set after GC. Look at heap occupancy right after old-generation or full collections over hours or days, not the sawtooth in between. A heap that climbs between collections but returns to the same floor afterwards is normal; the collector is simply lazy until it needs to work. A floor that keeps rising means objects are being retained. Oracle's troubleshooting guide makes the same point: an OutOfMemoryError can mean a leak, but it can also mean the heap, Metaspace or compressed class space is sized smaller than the application needs, so check that "before assuming a memory leak".

Signal two: the allocation rate. A high allocation rate with a flat live set is a garbage problem, not a leak. It costs CPU and GC frequency rather than memory. JDK Flight Recorder's allocation events show which code paths allocate most; typical culprits in a search service are building large intermediate collections per request, boxing in hot loops, or re-parsing the same JSON.

Signal three: process or container memory. A container is killed when the whole process exceeds its memory limit, and that limit counts far more than the heap. If the heap floor is flat but the container's resident memory grows, the growth is outside the heap: thread stacks, Metaspace, the code cache, direct ByteBuffers, memory-mapped files or a native library. Native Memory Tracking breaks JVM-managed native memory into categories. Enable it with -XX:NativeMemoryTracking=summary, take a jcmd <pid> VM.native_memory baseline, then compare with summary.diff later. Oracle documents a 5–10% performance overhead for NMT, so plan it as a diagnostic window, not a permanent setting. Memory-mapped index files are a special case for an Elasticsearch node: the OS page cache is meant to use spare memory, which is why the heap should not take all of it.

When the live set really grows. Use Flight Recorder's Old Object Sample events, which record allocation time, the allocation stack trace and the path back to a GC root. Oracle describes JFR's overhead as very low and designed to be safe for production, which is why it is the first tool to reach for there. Then take two or three heap dumps spaced apart (jcmd <pid> GC.heap_dump) and compare the dominator trees; the class whose retained size keeps rising is the lead. Common causes are unbounded caches, listeners never removed, ThreadLocal values left in pooled threads, and static collections keyed by request data.

What not to do. Do not raise -Xmx first; it hides the trend and makes the eventual dump larger and slower. Do not use desktop tools such as VisualVM against a production pod when JFR and jcmd give the same evidence with less risk.

Alternate interview scenarios

"The pod is OOMKilled but the heap graph never reached its maximum. What happened?"

The kernel killed the process because its total resident memory crossed the container limit, while the Java heap was still below -Xmx. So the excess is native memory or a heap maximum that leaves no room for everything else. I compare the container limit with the heap maximum plus thread stacks, Metaspace, code cache and direct buffers, then run Native Memory Tracking to see which category grows. The fix is either a smaller heap share or finding the native growth, not a bigger heap.

"Memory jumps right after every deployment, then settles. Is that a leak?"

Probably not. A fresh JVM loads classes, fills the code cache as the JIT compiles hot methods, and warms application caches, so resident memory rises and then plateaus. A leak keeps rising after warm-up. I compare the post-GC live set an hour after deploy with the same point a day later before calling it a problem.

<!-- include: java-spring/java-8-to-21-interview-questions#how-does-concurrenthashmap-work-internally --> <!-- why: Request threads share caches, in-flight maps and aggregations; ConcurrentHashMap is the usual concurrent map on that path. -->

<!-- include: java-spring/zalando-java-jvm-technical-round#what-are-the-costs-of-arraylist-linkedlist-hashmap-and --> <!-- why: Retrieval, ranking and facet assembly spend their time in these structures, so the cost model matters. -->

<!-- include: java-spring/zalando-java-jvm-technical-round#why-do-balanced-search-trees-matter-and-when-do-you-pick --> <!-- why: Sorted keys and range views appear in ranking windows, time-ordered buffers and dictionary-like state. -->

  1. Oracle Java SE 21 Troubleshooting Guide: Troubleshoot Memory Leaksdocs.oracle.com
  2. Oracle Java SE 21 HotSpot VM Guide: Native Memory Trackingdocs.oracle.com
  3. Oracle Java SE 21: The jcmd commanddocs.oracle.com
  4. Oracle Java SE 21 HotSpot Garbage Collection Tuning Guidedocs.oracle.com

Open as a standalone page

1.14 How does ConcurrentHashMap work internally?

Request threads share caches, in-flight maps and aggregations; ConcurrentHashMap is the usual concurrent map on that path.

The 30 second answer

ConcurrentHashMap lets many threads read and write without locking the whole map. Reads usually don't block, and writes only lock a small part of the table, so writers on different keys rarely wait for each other. Methods like compute, computeIfAbsent and merge are atomic per key. Size is only an estimate during updates, iterators never throw concurrent modification errors, and null keys or values aren't allowed.

What the Javadoc guarantees:

  • Retrievals generally don't block and reflect the most recently completed updates.
  • compute, computeIfAbsent, computeIfPresent and merge run atomically per key. Keep those functions short and never update other keys of the same map inside them.
  • size(), isEmpty() and containsValue() are only reliable when the map isn't being updated; mappingCount() returns a long.
  • Iterators are weakly consistent: they never throw ConcurrentModificationException and may or may not show updates made during iteration.
  • Null keys and values are rejected, so get returning null always means "absent".
ConcurrentHashMap<String, LongAdder> counts = new ConcurrentHashMap<>();
counts.computeIfAbsent(word, k -> new LongAdder()).increment();   // safe

if (!map.containsKey(k)) map.put(k, v);   // NOT atomic: check-then-act race
map.putIfAbsent(k, v);                    // atomic

[Impl] Since Java 8 the OpenJDK implementation no longer uses Java 7's segments: it uses CAS to insert into an empty bin, locks only the bin's first node for other updates, turns long bins into balanced trees, and lets several threads help with resizing. These details come from source-code comments, not the public spec, and can change.

Alternate interview scenarios

"How does a plain HashMap work internally, how are collisions handled since Java 8, and why is the default load factor 0.75?"

HashMap keeps an array of buckets whose size is a power of two. In OpenJDK, put spreads the key's hashCode by mixing its high bits into the low bits, picks the bucket from the low bits, and compares entries by hash and then equals. Keys that land in the same bucket are chained. Since Java 8 the OpenJDK implementation turns a long chain into a red-black tree once a bin reaches eight nodes and the table has at least sixty-four buckets; below that capacity it resizes instead, and a shrunk tree turns back into a list. That bounds the worst case when many keys collide, and tree ordering works best when keys are Comparable. These are implementation details from source comments, not the public contract. When size exceeds capacity times load factor, the table doubles and entries are redistributed. The Javadoc only says the default load factor of 0.75 offers a good trade-off between time and space; the source comments add that with well-distributed hashes, bin sizes then follow roughly a Poisson distribution with a small mean, so long chains are rare. If I know the size, I set the initial capacity so it never resizes. HashMap itself is not thread-safe.

"Why does ConcurrentHashMap throw an exception for a null key while HashMap accepts one?"

The Javadoc states the rule: like Hashtable and unlike HashMap, ConcurrentHashMap does not allow null keys or values, so put with a null throws NullPointerException. The design reason is ambiguity under concurrency. In a HashMap, get returning null can mean "absent" or "mapped to null", and a single thread can resolve that with containsKey. In a concurrent map another thread may change the map between the two calls, so the answer would be unreliable. Banning null makes null from get, putIfAbsent or compute always mean "no mapping", which those atomic methods rely on. If I need "known absent", I store an explicit sentinel value or use Optional.

"ConcurrentHashMap or Collections.synchronizedMap: when does the difference matter?"

synchronizedMap wraps every method in one lock, so all readers and writers serialise, and its Javadoc says you must manually synchronize on the map while iterating, or you risk non-deterministic behaviour. Compound actions such as "check then put" also need an outer lock. ConcurrentHashMap lets reads proceed without locking and locks only the affected bin on writes, gives weakly consistent iterators that never throw ConcurrentModificationException, and provides atomic computeIfAbsent, merge and putIfAbsent. synchronizedMap still fits when you need null keys or values, or a wrapped TreeMap or LinkedHashMap ordering, and contention is low.

  1. ConcurrentHashMap Javadoc (Java 21)docs.oracle.com
  2. ConcurrentMap Javadocdocs.oracle.com
  3. HashMap Javadoc (Java 21): capacity, load factor and rehashingdocs.oracle.com
  4. OpenJDK 21 HashMap source (hash spreading, treeify thresholds, Poisson note)github.com
  5. Collections.synchronizedMap Javadoc (Java 21): manual synchronization when iteratingdocs.oracle.com

Canonical answer

1.15 What are the costs of ArrayList, LinkedList, HashMap and TreeMap, and how do you choose?

Retrieval, ranking and facet assembly spend their time in these structures, so the cost model matters.

The 30 second answer

ArrayList gives constant-time index access and amortized constant-time append, but inserting in the middle shifts elements. LinkedList only wins when you already hold the position, and it has poor cache behaviour. HashMap gives expected constant-time get and put without order; TreeMap gives guaranteed log n and keeps keys sorted. Pick the contract you need, then check the cost.

From the Javadoc.

  • ArrayList: get, set, size and iteration run in constant time; add runs in amortized constant time; other operations are roughly linear. The constant factor is low compared with LinkedList.
  • LinkedList: a doubly linked list; operations that index into it traverse from the nearer end, so get(i) is linear. Insert and remove are constant time only once you have an iterator at the spot.
  • HashMap: constant-time get and put assuming the hash function spreads keys well; iteration is proportional to capacity plus size; no ordering guarantee.
  • TreeMap: a red-black tree with guaranteed log n containsKey, get, put and remove, keys sorted by natural order or a comparator, and range views such as subMap and headMap.

Choosing. Ask which contract you need: index access, insertion order, sorted order, uniqueness, or fast membership. A list you append to and read by index is an ArrayList. A queue or stack is an ArrayDeque, which beats LinkedList in practice. Fast lookup without order is a HashMap; insertion order adds LinkedHashMap; sorted keys or ranges need a TreeMap.

Why LinkedList rarely wins. Each node is a separate object with two pointers, so memory use is higher and traversal jumps around memory. Even the "insert in the middle" case needs a linear walk to reach the middle unless you already hold a ListIterator.

Alternate interview scenarios

"ArrayList or LinkedList for a list you mostly append to and then read by index?"

ArrayList. Append is amortized constant and index reads are constant, while LinkedList makes every get(i) a walk. LinkedList only helps if you insert or remove at a position you already hold through an iterator, and even then the memory and cache cost often cancel the gain.

"Fail-fast versus fail-safe iterators, HashSet, and HashMap resizing."

These come up together.

  • Fail-fast: ArrayList, HashMap and the other classic collections throw ConcurrentModificationException if the collection is structurally modified during iteration, except through the iterator's own remove. The Javadoc says this is "on a best-effort basis" and should be used "only to detect bugs".
  • "Fail-safe": not a Javadoc term. The concurrent collections use two different designs: CopyOnWriteArrayList iterates a snapshot, so it never sees later writes; ConcurrentHashMap iterators are weakly consistent, so they may or may not see concurrent changes. Neither throws.
  • HashSet is "backed by a hash table (actually a HashMap instance)": each element is a key with a dummy value, so it has the same hashing, equals/hashCode contract and costs as HashMap.
  • Capacity and resizing: HashMap starts with 16 buckets by default and rehashes to about twice the buckets when the entries exceed capacity times the load factor, 0.75 by default, which the Javadoc calls "a good tradeoff between time and space costs". If you know the size, presize it; in Java 19 and later HashMap.newHashMap(n) computes the capacity for n entries.
import java.util.*;
import java.util.concurrent.*;

public class FailFast {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>(List.of("a", "b", "c"));
        try {
            for (String s : list) if (s.equals("a")) list.remove(s);   // structural change mid-iteration
        } catch (ConcurrentModificationException e) {
            System.out.println("ArrayList: " + e.getClass().getSimpleName());
        }
        list.removeIf(s -> s.equals("b"));                             // the safe way
        System.out.println("removeIf: " + list);

        List<String> cow = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
        for (String s : cow) if (s.equals("a")) cow.add("d");          // iterates a snapshot
        System.out.println("CopyOnWriteArrayList: " + cow);

        Map<String, Integer> chm = new ConcurrentHashMap<>(Map.of("a", 1, "b", 2));
        for (String k : chm.keySet()) chm.remove(k);                   // weakly consistent, no exception
        System.out.println("ConcurrentHashMap: " + chm);
    }
}

Verified with OpenJDK 21.0.13. It prints ArrayList: ConcurrentModificationException, removeIf: [c] (the earlier loop had already removed a), CopyOnWriteArrayList: [a, b, c, d] and ConcurrentHashMap: {}.

  1. ArrayList Javadoc (Java 21)docs.oracle.com
  2. LinkedList Javadoc (Java 21)docs.oracle.com
  3. HashMap Javadoc (Java 21)docs.oracle.com
  4. TreeMap Javadoc (Java 21): red-black tree, guaranteed log(n)docs.oracle.com
  5. Zalando Engineering: Interview preparation tips for Java developers (June 2016)engineering.zalando.com
  6. CopyOnWriteArrayList Javadoc (Java 21): snapshot iteratorsdocs.oracle.com
  7. java.util.concurrent package summary (Java 21): weakly consistent iteratorsdocs.oracle.com
  8. HashSet Javadoc (Java 21): backed by a HashMapdocs.oracle.com

Canonical answer

1.16 Why do balanced search trees matter, and when do you pick TreeMap over HashMap?

Sorted keys and range views appear in ranking windows, time-ordered buffers and dictionary-like state.

The 30 second answer

A binary search tree finds a key in time proportional to its height. Insert sorted keys into a plain one and the height becomes n, so lookups turn linear. Balanced trees such as red-black trees keep the height logarithmic. Java's TreeMap is one, with guaranteed log n operations and sorted keys. I choose it when I need order or ranges, and HashMap otherwise.

Binary search trees. Each node's left subtree holds smaller keys and its right subtree larger ones, so search, insert and delete take time proportional to the tree's height. With random inserts the height is close to log n; with sorted inserts a naive tree degenerates into a linked list of height n.

Balancing. AVL trees and red-black trees rotate nodes on insert and delete to keep the height logarithmic. Red-black trees allow slightly more imbalance in exchange for fewer rotations, which is why many libraries use them. TreeMap and TreeSet are red-black trees with guaranteed log n operations, and HashMap turns very long collision chains into small red-black trees so a bad hash does not make lookups linear.

TreeMap versus HashMap. HashMap is expected constant time and unordered. TreeMap is log n and sorted, and adds what a hash map cannot do: firstKey, floorKey, ceilingKey, headMap, tailMap and subMap. Use it for "the price band containing 42 euros", "all events between two timestamps", or ordered iteration without a separate sort.

NavigableMap<Integer, String> bands = new TreeMap<>(Map.of(0, "budget", 50, "mid", 150, "premium"));
bands.floorEntry(42).getValue();   // "budget": the greatest key <= 42
bands.subMap(0, true, 100, false); // keys in [0, 100)

Ordering must match equality. A TreeMap compares keys with compareTo or its comparator, never equals, so a comparator that returns 0 for different objects merges them. See equals, hashCode and TreeSet ordering.

On disk. Databases use B+ trees instead, many keys per node to keep the tree shallow for block storage: see B+ tree lookups. Lucene's terms dictionary uses yet another structure: What is an FST?.

  1. TreeMap Javadoc (Java 21): red-black tree, guaranteed log(n)docs.oracle.com
  2. HashMap Javadoc (Java 21)docs.oracle.com
  3. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu

Canonical answer

1.17 Which Map implementations does Java offer beyond HashMap, and when do you pick each?

The 30 second answer

I pick a Map by the contract I need. HashMap for plain lookups, LinkedHashMap when insertion or access order matters, which also gives a small LRU cache. TreeMap for sorted keys and range queries in guaranteed log time. EnumMap for enum keys, backed by an array. ConcurrentHashMap or ConcurrentSkipListMap when threads share it. WeakHashMap and IdentityHashMap are special cases with traps worth naming.

Public candidate reviews of Zalando interviews mention "maps, its implementations and working" as a topic. HashMap internals are covered in the ConcurrentHashMap answer earlier in this chapter; this answer is about choosing between the implementations, which is where interviewers usually push next.

Start from the contract, not the class. Ask what the caller needs: plain lookup, a predictable iteration order, sorted keys and range queries, enum keys, safe sharing between threads, or special key semantics. Each standard implementation exists for one of those.

ImplementationWhat it guaranteesTypical use
HashMapExpected constant-time get and put with a good hashCode; no order guaranteeDefault lookup table
LinkedHashMapInsertion order, or access order when constructed with accessOrder = trueStable output order, small LRU caches
TreeMapRed-black tree, sorted by natural order or a Comparator, guaranteed log(n) for get, put, removeRange queries: floorKey, ceilingKey, subMap
EnumMapKeys from one enum type, stored internally as an array, iterated in declaration orderPer-status or per-type counters
ConcurrentHashMapThread-safe, non-blocking reads, atomic compute and mergeShared caches and counters
ConcurrentSkipListMapSorted and concurrent, expected average log(n), weakly consistent iteratorsShared sorted index, time-ordered buffers
WeakHashMapEntries disappear once the key is no longer strongly reachableMetadata attached to objects you do not own
IdentityHashMapCompares keys with == instead of equalsGraph traversal, serialization bookkeeping

LinkedHashMap as an LRU cache. The Javadoc says an access-ordered LinkedHashMap is "well-suited to building LRU caches", and removeEldestEntry lets you evict on insert:

public class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxEntries;

    public LruCache(int maxEntries) {
        super(16, 0.75f, true);            // true = access order, so get() moves an entry to the end
        this.maxEntries = maxEntries;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxEntries;        // evict the least recently accessed entry
    }
}

Verified with OpenJDK 21.0.13: with a capacity of two, putting dress and shoes, reading dress, then putting jacket leaves [dress, jacket]. Two caveats to say out loud. In access-order mode even get is a structural modification, so the class is not safe to share without external synchronization. For a production cache that many request threads share, a purpose-built cache library or a ConcurrentHashMap with a bounded eviction policy is the better answer.

TreeMap versus ConcurrentSkipListMap. Both are NavigableMaps with sorted keys and range views. TreeMap is single-threaded with a guaranteed log(n) bound. ConcurrentSkipListMap lets threads insert and read at the same time with an expected, not guaranteed, log(n) cost, and its iterators are weakly consistent. Use it when the sorted structure is shared, for example a time-ordered buffer of index updates.

The two traps. WeakHashMap holds keys weakly but values strongly, and the Javadoc warns that a value that refers back to its own key, directly or indirectly, prevents the key from ever being collected, which turns the "self-cleaning" map into a leak. IdentityHashMap "intentionally violates Map's general contract" by comparing with ==; two equal Strings built separately are two different keys.

How to close the answer. "In a search service, most maps are HashMaps inside one request. Shared state is a ConcurrentHashMap with atomic compute or merge, sorted shared state is a skip list, and anything that must evict is a real bounded cache, not an unbounded map."

Alternate interview scenarios

"You need the top N most recent queries, kept sorted, and several threads write to it. Which structure?"

I want ordering and concurrency together, so I reach for ConcurrentSkipListMap keyed by timestamp plus a tie-breaker, because two queries can share a millisecond. Writers call put, and a trimming step calls pollFirstEntry while the size is above N. Readers iterate descendingMap() for newest first, accepting that the view is weakly consistent. If only one thread writes, a TreeMap behind a lock is simpler and just as good.

"Why might an EnumMap beat a HashMap keyed by an enum?"

The Javadoc says enum maps are represented internally as arrays, which it calls extremely compact and efficient, so a lookup is an index by ordinal, with no hashing and no collisions. Iteration follows the enum's declaration order, which keeps reports and logs stable. Null keys are not allowed, which is usually what you want for an enum key anyway.

<!-- include: java-spring/java-8-to-21-interview-questions#what-are-functional-interfaces-and-lambdas --> <!-- why: Search code uses lambdas heavily in collectors, callbacks and comparator chains. -->

<!-- include: java-spring/java-8-to-21-interview-questions#how-does-the-stream-api-work-internally --> <!-- why: Scoring, filtering and grouping pipelines are easy to write as streams, and easy to get wrong on large result sets. -->

<!-- include: java-spring/java-8-to-21-interview-questions#what-is-the-difference-between-future-and-completablefuture --> <!-- why: A search API layer fans out to Elasticsearch, ranking and features; composition and errors live in CompletableFuture. -->

<!-- include: java-spring/java-8-to-21-interview-questions#how-does-optional-help-avoid-null-checks --> <!-- why: Missing fields on catalog documents are normal; Optional is how many codebases make that absence explicit. -->

<!-- include: java-spring/java-8-to-21-interview-questions#what-is-the-difference-between-default-and-static-methods --> <!-- why: Shared search contracts often grow default methods, and you need to know what that does to implementors. -->

<!-- include: java-spring/java-8-to-21-interview-questions#how-would-you-migrate-an-application-from-java-8-to-java-21 --> <!-- why: A long-lived search service is usually mid-migration, where virtual threads, records and removed APIs come up. -->

<!-- include: api-performance/api-latency-optimization#how-do-you-make-sure-an-application-meets-its-performance --> <!-- why: Whether a search service meets its SLOs is a measurement problem before it is a tuning problem. -->

<!-- include: java-spring/java-8-to-21-interview-questions#how-do-you-troubleshoot-high-cpu-in-a-java-service --> <!-- why: A hot query or analysis path shows up as CPU first; you need a method for finding the frames that burn it. -->

  1. Java SE 21 API: LinkedHashMapdocs.oracle.com
  2. Java SE 21 API: TreeMapdocs.oracle.com
  3. Java SE 21 API: EnumMapdocs.oracle.com
  4. Java SE 21 API: ConcurrentSkipListMapdocs.oracle.com
  5. Java SE 21 API: WeakHashMapdocs.oracle.com
  6. Java SE 21 API: IdentityHashMapdocs.oracle.com

Open as a standalone page

1.18 What are functional interfaces and lambdas?

Search code uses lambdas heavily in collectors, callbacks and comparator chains.

The 30 second answer

A functional interface is an interface with exactly one abstract method, like Runnable, Function or Predicate. A lambda is just a short way to write an implementation of that one method, so I can pass behavior around like data. The FunctionalInterface annotation is optional; it only asks the compiler to fail if someone adds a second abstract method.

A functional interface has exactly one abstract method. Default methods, static methods and methods that override Object methods (like equals) don't count. A lambda expression or a method reference can be used wherever such an interface is the target type; the compiler infers the parameter types from that target.

Predicate<String> isEmpty = s -> s.isEmpty();
Function<String, Integer> len = String::length;   // method reference
Runnable task = () -> System.out.println("run");

@FunctionalInterface is optional. It documents intent and makes the compiler reject the interface if it stops having exactly one abstract method. The standard shapes live in java.util.function: Function, Supplier, Consumer, Predicate, BiFunction, UnaryOperator, plus primitive versions like IntPredicate that avoid boxing.

Lambdas can use local variables from the enclosing scope only if those variables are final or effectively final.

Follow-up: Can a functional interface have default methods? Yes, any number; only abstract methods count.

  1. JLS 9.8 Functional Interfacesdocs.oracle.com
  2. JLS 15.27 Lambda Expressionsdocs.oracle.com
  3. java.util.functiondocs.oracle.com

Canonical answer

1.19 How does the Stream API work internally?

Scoring, filtering and grouping pipelines are easy to write as streams, and easy to get wrong on large result sets.

The 30 second answer

A stream is a pipeline: a source, some intermediate steps like filter and map, and one terminal step like collect. The intermediate steps only describe the work. When the terminal step runs, each element flows through all the steps one at a time, not stage by stage. A stream can be used once, and parallel streams split the source and run on the common fork-join pool.

A stream pipeline has three parts:

  1. Source: a collection, array, generator or I/O channel.
  2. Intermediate operations (filter, map, sorted): they return a new stream and are lazy.
  3. Terminal operation (collect, forEach, reduce, findFirst): it triggers processing and consumes the stream.
List<String> names = users.stream()     // source
    .filter(User::active)               // intermediate
    .map(User::name)                    // intermediate
    .toList();                          // terminal

Stateless operations like filter and map are fused, so each element passes through the whole chain before the next one starts. Stateful operations such as sorted or distinct may need to see all elements first. After a terminal operation the stream is consumed; reusing it throws IllegalStateException.

For parallel streams the source is split with a Spliterator, and the chunks run on ForkJoinPool.commonPool(). The docs require behavioral parameters to be non-interfering (don't modify the source during the pipeline) and preferably stateless; otherwise results can be wrong or nondeterministic.

  1. java.util.stream package summary: stream operations and pipelinesdocs.oracle.com
  2. Spliterator Javadocdocs.oracle.com

Canonical answer

1.20 What is the difference between Future and CompletableFuture?

A search API layer fans out to Elasticsearch, ranking and features; composition and errors live in CompletableFuture.

The 30 second answer

A Future only lets you check if the work is done, cancel it, or block on get to wait for the result. CompletableFuture, added in Java 8, lets me chain the next steps without blocking: transform the result, call another async step, combine two results, or wait for many. It also handles errors in the chain. For blocking I/O, I pass my own executor instead of using the common pool.

Future<T> (Java 5) offers isDone(), cancel() and a blocking get(). You can't attach a callback or compose two futures.

CompletableFuture<T> (Java 8) implements both Future and CompletionStage:

  • thenApply transforms the result; thenCompose chains another async call (like flatMap).
  • thenCombine merges two independent results; allOf / anyOf wait for several.
  • exceptionally, handle and whenComplete deal with failures.
  • complete(value) lets you finish it by hand.
CompletableFuture<List<Order>> orders =
    CompletableFuture.supplyAsync(() -> loadUser(id), ioPool)
        .thenCompose(u -> CompletableFuture.supplyAsync(() -> loadOrders(u), ioPool))
        .orTimeout(2, TimeUnit.SECONDS)          // Java 9
        .exceptionally(ex -> List.of());

Async methods without an executor argument use ForkJoinPool.commonPool(). [Opinion] For blocking I/O pass a dedicated executor so you don't starve the common pool, which parallel streams also share. On Java 21, a virtual-thread-per-task executor is a good fit.

Alternate interview scenarios

"An application uses CompletableFuture heavily, and response times grow under load. How do you find the concurrency bottleneck?"

The usual cause is a starved executor, not CompletableFuture itself. The Javadoc says async methods without an explicit executor use the common ForkJoinPool. That pool is sized to the CPU count and shared with parallel streams, so blocking I/O on it lets a few slow calls occupy every worker. Other causes are a join() or get() inside a stage, which blocks a pool thread while it waits for another stage on the same pool, and a bounded custom pool whose queue grows. To find it, I take a thread dump, see which pool's threads are busy and what they block on, and look at queue length and task wait time for each executor. The fix is a dedicated, bounded executor for each kind of blocking work, or virtual threads, plus orTimeout on every remote call.

  1. Future Javadocdocs.oracle.com
  2. CompletableFuture Javadocdocs.oracle.com

Canonical answer

1.21 How does Optional help avoid null checks?

Missing fields on catalog documents are normal; Optional is how many codebases make that absence explicit.

The 30 second answer

Optional puts "there might be no value" into the method's return type, so the caller can't forget to handle it. Instead of if-not-null checks, I chain map, filter and then orElse, orElseGet or orElseThrow. The Javadoc says it is mainly meant for return values, so I don't use it for fields or parameters, and an Optional variable itself should never be null.

String city = findUser(id)            // Optional<User>
    .map(User::address)
    .map(Address::city)
    .orElse("unknown");

User u = findUser(id).orElseThrow(() -> new NotFoundException(id));

Useful details:

  • orElse(x) always evaluates x; orElseGet(supplier) evaluates it only when empty. Use orElseGet when the default is expensive.
  • Optional.of(null) throws; use Optional.ofNullable for values that may be null.
  • Later additions: ifPresentOrElse, or and stream() (Java 9), orElseThrow() with no arguments (Java 10), isEmpty() (Java 11).
  • Avoid get() without checking; prefer orElseThrow(), which says the same thing more clearly.

The Javadoc API note says Optional is intended primarily as a method return type where there is a clear need to represent "no result". [Opinion] Don't use it for fields, parameters or collections; return an empty collection instead of Optional<List<T>>.

  1. Optional Javadoc (API note)docs.oracle.com

Canonical answer

1.22 What is the difference between default and static methods in interfaces?

Shared search contracts often grow default methods, and you need to know what that does to implementors.

The 30 second answer

A default method has a body inside the interface and is inherited, so implementing classes get it for free but can override it. It was added so the JDK could add methods like forEach to existing interfaces without breaking every implementation. A static interface method belongs only to the interface; you call it through the interface name, and implementing classes don't inherit it.

interface Greeter {
    String name();
    default String greet() { return "Hello, " + name(); }   // inherited, overridable
    static Greeter of(String n) { return () -> n; }          // Greeter.of("Amin")
}

Default methods enabled interface evolution in Java 8: Iterable.forEach, Collection.stream() and Map.getOrDefault were added without breaking existing implementations.

Conflict rules: a method declared in a class wins over a default method. If a class inherits the same default method from two unrelated interfaces, it must override it, and can pick one with A.super.greet().

Static interface methods are not inherited by implementing classes or subinterfaces; you must call them as Greeter.of(...).

Alternate interview scenarios

"Interface with default methods or abstract class: which do you pick?"

An abstract class can hold state in instance fields, constructors and non-public members, but a class can extend only one. An interface can be implemented many times over and, since Java 8, carry default methods, but it has no instance state: its fields are implicitly public static final. I use an interface for a capability or contract and default methods for small conveniences built from its abstract methods, and an abstract class when implementations share state or a fixed algorithm with steps to fill in (template method). The related prompt "encapsulation versus abstraction" splits the same way: abstraction is what a type exposes, such as an interface; encapsulation is hiding how it does it, such as private fields behind methods.

  1. JLS 9.4 Method Declarationsdocs.oracle.com
  2. JLS 8.4.8 Inheritance, Overriding, and Hidingdocs.oracle.com
  3. JLS 21 §8.1.1.1: abstract classesdocs.oracle.com

Canonical answer

1.23 How would you migrate an application from Java 8 to Java 21?

A long-lived search service is usually mid-migration, where virtual threads, records and removed APIs come up.

The 30 second answer

I'd upgrade build tools and libraries first, because old bytecode tools and frameworks are the usual blockers. Then I scan for use of internal JDK APIs, add back Java EE pieces like JAXB that were removed in Java 11, and fix reflection that strong encapsulation now blocks. I check removed JVM flags, run the full test suite, roll out gradually, and only then adopt new features.

  1. Tooling and libraries first. Maven or Gradle plugins, Lombok, Mockito, ByteBuddy, ASM and your framework versions must support Java 21.
  2. Find internal API use: jdeps --jdk-internals app.jar lists dependencies on internal JDK classes and suggests replacements.
  3. Removed modules. Java 11 removed the Java EE and CORBA modules (JEP 320), so JAXB, JAX-WS and the common annotations must come in as normal dependencies.
  4. Strong encapsulation. Since Java 17 (JEP 403) deep reflection into JDK internals fails by default. Upgrade the library; use --add-opens only as a temporary workaround.
  5. JVM flags. Old GC logging flags like -XX:+PrintGCDetails are replaced by -Xlog; the CMS collector was removed in Java 14 (JEP 363); some removed flags stop the JVM from starting.
  6. Test and roll out. Full test suite and performance tests, then a canary deployment.
  7. Adopt new features only after it runs: records, pattern matching, virtual threads.

Spring Boot 3 requires Java 17 or later and moves from javax.* to jakarta.* packages. [Opinion] So a Java 8 Spring Boot 2 app is usually a Java and framework migration together; doing it in steps (for example Java 17 on Spring Boot 2.7 first, then Spring Boot 3 and Java 21) reduces risk.

  1. Oracle JDK 21 Migration Guidedocs.oracle.com
  2. JEP 320: Remove the Java EE and CORBA Modulesopenjdk.org
  3. JEP 403: Strongly Encapsulate JDK Internalsopenjdk.org
  4. JEP 363: Remove the CMS Garbage Collectoropenjdk.org
  5. jdepsdocs.oracle.com
  6. Spring Boot system requirementsdocs.spring.io

Canonical answer

1.24 How do you make sure an application meets its performance requirements?

Whether a search service meets its SLOs is a measurement problem before it is a tuning problem.

The 30 second answer

I start by agreeing on a concrete target as percentiles, for example p99 under a set time at a given request rate. Then I write a load test for that target and run it before each release. In production I watch latency, traffic, errors and saturation with alerts on p99. When we miss the target, I use traces to find where time goes before changing anything.

First I agree on a target as percentiles, for example p99 under 300 ms at a given request rate. I write a load test for it, with k6 or JMeter, and I run it in CI or before each release. In production I track latency, traffic, errors and saturation on dashboards, with alerts on p99. If we miss the target, I use traces to find where the time goes before I change anything.

  1. Google SRE book: Service Level Objectivessre.google
  2. Google SRE book: The four golden signalssre.google
  3. k6 docs: Thresholdsgrafana.com

Canonical answer

1.25 How do you troubleshoot high CPU in a Java service?

A hot query or analysis path shows up as CPU first; you need a method for finding the frames that burn it.

The 30 second answer

First I confirm it's the Java process and check whether garbage collection is burning the CPU, using the GC logs. If not, I find the hot threads: take a few thread dumps a few seconds apart and see which stacks stay busy. Then I record a short Flight Recorder profile to find the hot methods. Typical causes are busy loops, retry storms, heavy regex or serialization, and excessive logging.

  1. Confirm the source. Is the JVM using the CPU, and is it GC? Check GC logs (-Xlog:gc*) or jstat -gcutil <pid> 1s. Constant GC usually means memory pressure, so switch to the GC-pause playbook below.
  2. Find hot threads. On Linux, top -H -p <pid> shows per-thread CPU. Convert the busy thread ID to hex and find it as nid=0x... in a thread dump from jcmd <pid> Thread.print. Take 3 to 5 dumps a few seconds apart; stacks that stay RUNNABLE in the same code are the suspects.
  3. Profile. Record with JDK Flight Recorder, which has low overhead and is designed for production use:
   jcmd <pid> JFR.start duration=60s filename=cpu.jfr

Open the file in JDK Mission Control and look at hot methods and threads.

  1. Fix and verify. Common causes: tight loops, retry storms without backoff, catastrophic regex backtracking, serializing huge objects, logging in hot paths, lock spinning. Measure again after the fix.
  1. jcmddocs.oracle.com
  2. Troubleshooting Guide: Diagnostic Tools (JFR)docs.oracle.com
  3. java command: -Xlogdocs.oracle.com
  4. jstatdocs.oracle.com

Canonical answer

Search fundamentals and Elasticsearch

Mapping traps, variant modelling, write-heavy indexing, cluster coordination, vectors, and two public Zalando Elasticsearch stories.

A fashion catalog search stack is built on Lucene: inverted indexes, analysis, scoring and sharded near-real-time Elasticsearch, with vector retrieval on top. The applied questions that follow those fundamentals cover mapping traps, variant modelling, write-heavy indexing, cluster coordination and two public Zalando Elasticsearch incidents.

2.1 What is an inverted index, and how does Lucene store it?

Every term query and filter on a catalog is a walk over this structure.

The 30 second answer

Instead of storing each document's words, an inverted index maps each term to the documents that contain it, often with frequencies and positions. A query looks up its terms and combines their postings lists instead of scanning documents. Lucene writes it as immutable segments, each with a terms dictionary, postings, stored fields, doc values and a live-documents file for deletes.

The idea. A forward index lists the terms in each document. An inverted index flips it: for every term, a postings list of the documents that contain it, sorted by document ID, usually with term frequency and optionally positions and offsets. Building it means analysing text into terms, then grouping postings by term. Introduction to Information Retrieval describes this as a dictionary of terms plus postings lists, with the dictionary kept in memory and postings on disk.

In Lucene. The index file-format documentation describes an index as a set of segments, each a self-contained index. Each segment holds, among other files:

  • Term dictionary and term index (.tim, .tip): the terms and an in-memory index into them.
  • Postings (.doc, .pos, .pay): document IDs with frequencies, positions and payloads, encoded in packed integer blocks for fast decoding.
  • Stored fields (.fdt, .fdx): the original values returned in results.
  • Doc values: a column-oriented copy of fields for sorting and aggregations, which the inverted index is bad at.
  • Points and vectors: structures for numeric ranges and for kNN search.
  • Live documents (.liv): which documents in the segment are still live after deletes.

Why both an inverted index and doc values. Matching needs term to documents; sorting by price or counting facets needs document to value. That is why Elasticsearch maps text both as a text field for matching and often as a keyword field with doc values for aggregations.

Alternate interview scenarios

"Search for a red linen dress. Walk me through what happens inside one shard."

The query is analysed with the field's search analyzer into terms such as red, linen and dress. Each term is looked up in the terms dictionary, which gives a pointer into its postings. The engine walks the postings, intersecting or unioning them according to the Boolean query, scores matching documents, keeps the top hits, and returns them to the coordinating node, which merges results from all shards and then fetches stored fields for the final page.

  1. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu
  2. Lucene 9.9 index file formats (segments, term dictionary, postings, live documents)lucene.apache.org
  3. Elasticsearch docs: Anatomy of an analyzerelastic.co

Canonical answer

2.2 How do you evaluate Boolean AND and OR over postings lists?

Brand, size and market filters are intersections and unions of postings lists.

The 30 second answer

Postings are sorted by document ID, so AND is a merge-like intersection: compare the heads, advance the smaller one, keep matches. Start with the rarest term so the candidate set shrinks fast. OR is a merge that keeps every ID once. Skip pointers let AND jump ahead in long lists, and phrase queries also need positions, not just document IDs.

Intersection. With two sorted lists of lengths m and n, walk both with two cursors: equal IDs are a match, otherwise advance the smaller. That costs O(m + n). For several terms, Introduction to Information Retrieval recommends processing in order of increasing document frequency, so the intermediate result is never larger than the rarest list.

Union. Merge the sorted lists, emitting each ID once: also O(m + n).

import java.util.*;

public class Postings {
    // Both lists are sorted doc IDs without duplicates.
    static List<Integer> and(List<Integer> a, List<Integer> b) {
        List<Integer> out = new ArrayList<>();
        int i = 0, j = 0;
        while (i < a.size() && j < b.size()) {
            int x = a.get(i), y = b.get(j);
            if (x == y) { out.add(x); i++; j++; }
            else if (x < y) i++;                         // advance the smaller cursor
            else j++;
        }
        return out;
    }

    static List<Integer> or(List<Integer> a, List<Integer> b) {
        List<Integer> out = new ArrayList<>();
        int i = 0, j = 0;
        while (i < a.size() || j < b.size()) {
            if (j == b.size() || (i < a.size() && a.get(i) < b.get(j))) out.add(a.get(i++));
            else if (i == a.size() || b.get(j) < a.get(i)) out.add(b.get(j++));
            else { out.add(a.get(i)); i++; j++; }        // same doc in both lists
        }
        return out;
    }

    // AND over many terms: start from the rarest list so the candidate set shrinks fastest.
    static List<Integer> andAll(List<List<Integer>> lists) {
        List<List<Integer>> sorted = new ArrayList<>(lists);
        sorted.sort(Comparator.comparingInt(List::size));
        List<Integer> acc = sorted.get(0);
        for (int k = 1; k < sorted.size() && !acc.isEmpty(); k++) acc = and(acc, sorted.get(k));
        return acc;
    }

    public static void main(String[] args) {
        Map<String, List<Integer>> index = Map.of(
            "red", List.of(1, 3, 5, 8, 13), "dress", List.of(2, 3, 8, 21), "linen", List.of(3, 21));
        System.out.println(andAll(List.of(index.get("red"), index.get("dress"), index.get("linen")))); // [3]
        System.out.println(or(index.get("red"), index.get("linen")));                                 // [1, 3, 5, 8, 13, 21]
    }
}

Verified with OpenJDK 21.0.13: it prints [3] and [1, 3, 5, 8, 13, 21].

Making AND faster. Skip pointers, or skip lists in Lucene's postings format, let the cursor on a long list jump past many IDs that cannot match. When one list is far shorter, binary-searching or galloping through the longer one beats a linear walk.

Beyond Boolean. Phrase queries such as "linen dress" also check that positions are adjacent, so they need a positional index. Real engines do not intersect then score; they score while iterating and can skip documents that cannot reach the top K, see the top-K question below.

  1. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu
  2. Lucene 9.9 index file formats (segments, term dictionary, postings, live documents)lucene.apache.org

Canonical answer

2.3 What happens during text analysis, and why must index and query analysis match?

Index-time and query-time analysis must match, or fashion terms never hit the index.

The 30 second answer

Analysis turns text into the terms that are indexed and searched: character filters clean the text, a tokenizer splits it, and token filters lowercase, stem, remove stop words or add synonyms. A query only matches if it produces the same terms as the document did, so the analysis at index time and search time must be compatible. Stemming and synonyms raise recall but can hurt precision.

The pipeline. Elasticsearch documents an analyzer as three building blocks: zero or more character filters, exactly one tokenizer, and zero or more token filters. Example for "Women's Running-Shoes":

  1. Character filter: strip HTML or map characters.
  2. Tokenizer: split into Women's, Running, Shoes.
  3. Token filters: lowercase, remove possessives, stem to run and shoe, maybe add synonyms such as sneaker.

Index-time and search-time. The same analyzer usually runs on both sides. If the index stems shoes to shoe but the query does not, shoes never matches. A search analyzer can differ on purpose, for example adding synonyms only at query time so you can change them without reindexing.

Trade-offs.

  • Stemming increases recall, but over-stemming merges unrelated words.
  • Stop words shrink the index, but removing them breaks phrase queries and short queries.
  • Synonyms fix vocabulary gaps but can drift: "jumper" means a sweater in British English and a pinafore dress in American English.
  • Languages: analyzers are language-specific, and compound languages such as German need decompounding. Zalando's job ad mentions consolidating multilingual search indices; per-language fields or indices are the usual answer.

Field types. Map free text as text, analysed, for matching, and exact values such as brand, SKU or size as keyword, not analysed, for filters, sorting and aggregations. Multi-fields index the same source both ways.

Debugging. The _analyze API shows exactly which tokens a string produces with a given analyzer, the first thing to run when "this should match but doesn't".

  1. Elasticsearch docs: Anatomy of an analyzerelastic.co
  2. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu
  3. Zalando Jobs: Senior Search Engineer, Search and Browse Core (job ad)jobs.zalando.com

Canonical answer

2.4 What is an FST, and why does Lucene use one for the terms dictionary?

The terms dictionary is how Elasticsearch finds a term's postings quickly at query time.

The 30 second answer

A finite-state transducer is a compact automaton that maps keys to outputs. It shares common prefixes and suffixes, so a large sorted set of terms fits in little memory. Lucene's BlockTree terms dictionary groups terms into blocks by shared prefix and keeps that prefix trie in memory as an FST, which points to the right block on disk.

What it is. A finite-state automaton recognises a set of strings; a transducer also emits an output along the path, for example a file pointer. A minimal FST merges shared prefixes, like a trie, and shared suffixes too, which a trie cannot. The Lucene util.fst package documents fast, low-memory construction of the minimal FST, with the restriction that inputs must be added in sorted order, plus a compact byte[] representation and iterators that behave like a sorted map.

How the terms dictionary uses it. The Lucene BlockTree terms dictionary "organizes all terms into blocks according to shared prefix, such that each block has enough terms, and then stores the prefix trie in memory as an FST as the index structure." A term lookup walks the FST to find the block, then reads that block from the .tim file. The full term list stays on disk; only the small prefix index sits on the heap.

Why this fits Lucene. Segments are written once and never modified, so the sorted-input requirement costs nothing: the segment's terms are already sorted when it is written. Because the structure is ordered, it also supports prefix enumeration, which helps prefix and wildcard queries, and the same machinery backs completion suggesters.

Comparisons.

  • Hash table: fast exact lookup, but no order and no prefix search.
  • B-tree: ordered and good for updates in place on disk, but larger, and Lucene never updates a segment in place.
  • Trie: prefix search, but one node per character costs far more memory. See tries and autocomplete.
  1. Lucene 9.11 org.apache.lucene.util.fst packagelucene.apache.org
  2. Lucene 9.11 BlockTree terms dictionary (prefix trie stored as an FST)lucene.apache.org
  3. Lucene 9.9 index file formats (segments, term dictionary, postings, live documents)lucene.apache.org

Canonical answer

2.5 What is a trie, and how would you code prefix autocomplete in Java?

Prefix autocomplete for a search box is the trie idea in application code, next to Lucene's FST.

The 30 second answer

A trie stores strings by shared prefix: each edge is a character, and a node marks where a word ends. Finding all words with a prefix costs the prefix length plus the nodes you visit, regardless of how many other words exist. The cost is memory, one node per character. For a compact read-only dictionary, search engines use finite-state transducers instead.

Structure. The root is the empty string. Each node maps a character to a child and has a flag for "a word ends here". Insert walks the characters, creating nodes as needed. Prefix search walks the prefix, then collects words below that node.

Costs. Insert and lookup are proportional to the word length. Collecting K completions costs the prefix length plus the nodes visited, so stop as soon as you have K. Memory is the weakness: one object per character with a child map. Shared prefixes help; unrelated words do not.

import java.util.*;

public class Autocomplete {
    private static final class Node {
        final TreeMap<Character, Node> children = new TreeMap<>(); // sorted, so results come out in order
        boolean word;
    }
    private final Node root = new Node();

    void insert(String w) {
        Node n = root;
        for (char c : w.toLowerCase(Locale.ROOT).toCharArray()) n = n.children.computeIfAbsent(c, k -> new Node());
        n.word = true;
    }

    List<String> complete(String prefix, int k) {
        Node n = root;
        String p = prefix.toLowerCase(Locale.ROOT);
        for (char c : p.toCharArray()) {
            n = n.children.get(c);
            if (n == null) return List.of();              // no word has this prefix
        }
        List<String> out = new ArrayList<>();
        collect(n, new StringBuilder(p), k, out);
        return out;
    }

    private void collect(Node n, StringBuilder sb, int k, List<String> out) {
        if (out.size() == k) return;                       // stop early: O(prefix + visited nodes)
        if (n.word) out.add(sb.toString());
        for (var e : n.children.entrySet()) {
            sb.append(e.getKey());
            collect(e.getValue(), sb, k, out);
            sb.deleteCharAt(sb.length() - 1);
            if (out.size() == k) return;
        }
    }

    public static void main(String[] args) {
        var t = new Autocomplete();
        for (String w : List.of("sneaker", "sneakers", "snow boots", "sandals", "Sneaker socks")) t.insert(w);
        System.out.println(t.complete("sne", 2));          // [sneaker, sneaker socks]
        System.out.println(t.complete("x", 3));            // []
    }
}

Verified with OpenJDK 21.0.13: it prints [sneaker, sneaker socks] and []. Using a TreeMap for children returns completions in alphabetical order; a HashMap would be faster per step but unordered.

Follow-ups to expect.

Alternate interview scenarios

"Design typeahead search for millions of queries per second."

The trie is the data structure; the design is about never computing completions at request time.

  • Precompute: aggregate query logs offline, or in a streaming job, into the top K completions for each prefix, ranked by frequency and freshness, and filter unsafe or zero-result queries.
  • Serve from memory: load a prefix-to-top-K map, or a compact trie or FST, into every serving node; a lookup is one in-memory read. Elasticsearch's completion suggester follows the same idea: its docs say it uses data structures that "enable fast lookups, but are costly to build and are stored in-memory".
  • Cache short prefixes: one- and two-character prefixes take most traffic and change slowly, so cache them at the edge or in the client with a short time to live. Debounce keystrokes in the client.
  • Scale and freshness: replicate the read-only index widely, shard by prefix if it does not fit on one node, and rebuild periodically with a blue-green swap. Add a small real-time layer only if trending queries must appear within minutes.
  • Per market and language: keep separate indexes per locale, which a multi-market shop needs anyway.
  1. TreeMap Javadoc (Java 21): red-black tree, guaranteed log(n)docs.oracle.com
  2. Lucene 9.11 org.apache.lucene.util.fst packagelucene.apache.org
  3. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu
  4. Elasticsearch docs: Suggesters (completion suggester)elastic.co

Canonical answer

2.6 What is BM25, and how does it differ from TF-IDF?

BM25 is the default lexical ranker in Elasticsearch, and the baseline a learned ranker has to beat.

The 30 second answer

BM25 scores a document for each query term from three things: how rare the term is, how often it appears in the document, and how long the document is. Unlike raw TF-IDF, repeated occurrences saturate, so the tenth mention adds little, and long documents are normalised. In Lucene, k1 controls the saturation and b the length normalisation, defaulting to 1.2 and 0.75.

TF-IDF. Weight a term by its frequency in the document times its inverse document frequency, so rare terms matter more. Two problems: score grows linearly with term frequency, so keyword stuffing wins, and long documents accumulate more matches.

BM25. It comes from the probabilistic relevance framework of Robertson and colleagues and is described in Introduction to Information Retrieval. For each query term it multiplies an IDF factor by a saturating term-frequency factor with length normalisation:

  • IDF: Lucene's BM25Similarity implements it as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)), which stays positive even for very common terms.
  • Term frequency: tf / (tf + k1 * (1 - b + b * docLen / avgDocLen)). As tf grows, the factor approaches 1, so extra occurrences add less and less.
  • Parameters: the Lucene Javadoc says k1 "controls non-linear term frequency normalization (saturation)" and b "controls to what degree document length normalizes tf values"; the no-argument constructor uses k1 = 1.2 and b = 0.75. These are Lucene's defaults, not universal constants, and Elasticsearch lets you configure them per field.

The textbook form also multiplies by (k1 + 1); it does not change the ranking, so Lucene leaves it out.

import java.util.*;

public class Bm25TopK {
    // One term's BM25 contribution in Lucene's form; k1 and b are passed in, not hard-coded.
    static double bm25(long docCount, long docFreq, int tf, int docLen, double avgLen, double k1, double b) {
        if (tf == 0 || docFreq == 0) return 0;
        double idf = Math.log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5));
        double norm = k1 * (1 - b + b * docLen / avgLen);
        return idf * tf / (tf + norm);
    }

    record Hit(int doc, double score) {}

    // Keep the K best hits in a min-heap: O(n log K) time, O(K) memory.
    static List<Hit> topK(List<Hit> hits, int k) {
        PriorityQueue<Hit> heap = new PriorityQueue<>(
            Comparator.comparingDouble(Hit::score).thenComparing(Hit::doc, Comparator.reverseOrder()));
        for (Hit h : hits) {
            if (heap.size() < k) heap.add(h);
            else if (heap.comparator().compare(h, heap.peek()) > 0) { heap.poll(); heap.add(h); }
        }
        List<Hit> out = new ArrayList<>(heap);
        out.sort(heap.comparator().reversed());
        return out;
    }

    public static void main(String[] args) {
        double k1 = 1.2, b = 0.75;                          // Lucene's BM25Similarity defaults
        long n = 1_000, df = 10;
        System.out.printf("tf=1:%.3f tf=5:%.3f tf=50:%.3f%n",
            bm25(n, df, 1, 10, 10, k1, b), bm25(n, df, 5, 10, 10, k1, b), bm25(n, df, 50, 10, 10, k1, b));
        System.out.printf("short doc:%.3f long doc:%.3f%n",
            bm25(n, df, 2, 5, 10, k1, b), bm25(n, df, 2, 40, 10, k1, b));
        var hits = List.of(new Hit(1, 0.4), new Hit(2, 2.1), new Hit(3, 1.7), new Hit(4, 2.1), new Hit(5, 0.9));
        System.out.println(topK(hits, 3));
    }
}

Verified with OpenJDK 21.0.13. It prints tf=1:2.072 tf=5:3.675 tf=50:4.451 (saturation), then short doc:3.314 long doc:1.545 (length normalisation), then the top three hits.

Product catalogs. Titles are short and equally long, so length normalisation matters less than in articles; some teams lower b on title fields. Change it only with a judgment set and the explain output. In e-commerce, text score is usually one signal among many, such as popularity, availability and personalisation, combined in a later ranking stage.

  1. Lucene 9.11 BM25Similarity Javadoc (defaults and IDF formula)lucene.apache.org
  2. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu

Canonical answer

2.7 What is the difference between query context and filter context, and why does it matter?

Filters on brand, size or market should not score, and they cache differently from query-context clauses.

The 30 second answer

A clause in query context contributes to the relevance score. A clause in filter context only decides yes or no and is not scored. Exact constraints such as size, brand, in stock or price range belong in filters. They skip scoring, Elasticsearch can cache frequently used filters, and they keep the score about text relevance rather than about whether a filter matched.

From the Elasticsearch docs. In query context, a clause answers "how well does this document match?" and adds to _score. In filter context, it answers "does this document match?" with yes or no. The docs list the benefits: filters do not compute scores, so they run faster; Elasticsearch automatically caches frequently used filters; and they use less CPU than full-text queries. Filter context applies to the filter and must_not clauses of a bool query and to filter aggregations, among others.

{"query": {"bool": {
  "must":   [{"match": {"title": "linen dress"}}],
  "filter": [{"term": {"size": "M"}}, {"term": {"in_stock": true}},
             {"range": {"price": {"lte": 80}}}]
}}}

Why it matters for relevance. If "size M" sat in must, every matching document would get the same small score boost, distorting the ranking for no reason. In filters, only the text decides the score.

Why it matters for latency. Facet and filter combinations repeat a lot in e-commerce, so cacheable filters help. Zalando's December 2025 post describes caching popular queries and filter combinations at several layers above Elasticsearch as well.

Common mistake. Putting the user's typed text into a filter, which loses ranking, or putting structured attributes extracted from the query into scoring. Zalando's public talks describe recognising entities such as brand or colour in the query and turning them into filters; treat that as a query-understanding step before the query reaches Elasticsearch.

  1. Elasticsearch docs: Query and filter contextelastic.co
  2. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Search (December 2025)engineering.zalando.com
  3. MICES 2026: Zalando Search: Architecture Behind Product Discovery (video)youtube.com

Canonical answer

2.8 How do you return the top K results without scoring and sorting every match?

Fashion catalogs are huge; you cannot score and sort every match just to return one page of results.

The 30 second answer

Keep a min-heap of size K while iterating matches: compare each score with the smallest in the heap and replace it if larger. That costs n log K instead of n log n and needs memory only for K. Search engines go further and skip documents that cannot beat the current Kth score, using per-block maximum scores, so many matches are never fully scored.

Heap. For n candidates and K results, a min-heap ordered by score keeps the best K seen so far. Each new candidate is compared with the heap's minimum, so the work is O(n log K) and memory O(K). Break ties on a stable key, such as document ID, so pagination is deterministic. The topK method in the BM25 snippet above does exactly this.

Skipping work. Introduction to Information Retrieval describes techniques that avoid scoring every match: champion lists of the strongest documents per term, impact-ordered postings, and tiered indexes. Lucene implements the modern version: a collector can call setMinCompetitiveScore to tell the scorer it "may safely ignore all documents whose score is less than the given minScore", and the postings format stores per-block maximum impacts so whole blocks can be skipped. This is why Elasticsearch does not count every hit exactly by default; track_total_hits controls it.

Distributed top K. Each shard returns its own top K and the coordinating node merges them, so a page from + size asks every shard for from + size hits. Deep pagination multiplies that cost; Elasticsearch caps it with index.max_result_window and recommends search_after for deep paging, which Zalando's 2025 post also names as a guardrail.

Second stage. In a two-stage ranker, the first stage returns a few hundred candidates and a heavier model re-ranks only those, which keeps the expensive part bounded. Zalando's 2024 ranking-platform paper reports offline results for the top 500 candidates passed to the ranker.

  1. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu
  2. Lucene 9.11 Scorable.setMinCompetitiveScorelucene.apache.org
  3. Elasticsearch docs: Paginate search results (search_after)elastic.co
  4. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Search (December 2025)engineering.zalando.com
  5. arXiv 2409.02856: Building a Scalable, Effective, and Steerable Search and Ranking Platform (Zalando, 2024)arxiv.org

Canonical answer

2.9 A relevant product ranks too low or does not appear. How do you debug it?

A missing or buried product is a mapping, analysis or ranking bug you have to isolate systematically.

The 30 second answer

First I separate matching from ranking. If the product does not match, I check analysis with the analyze API, the mapping, and any filters or redirects that excluded it. If it matches but ranks low, I read the explain output to see which clauses, field weights and term statistics produced the score, then look at boosts and later ranking stages. I fix the cause and add the query to a judgment set.

Step 1: does it match at all? Run the query restricted to that product's ID.

  • If it does not match, compare the tokens: run _analyze on the product's title and on the query with the field's analyzers. Stemming, a missing synonym, a decompounding gap or a keyword field used for text are common causes.
  • Check the mapping: is the field indexed, and is the value in the field you query?
  • Check filters: an availability filter, a market filter or an entity wrongly turned into a filter, such as a colour recognised from an ambiguous word, can exclude it silently.
  • Check freshness: if the product was just updated, a refresh may not have happened yet.

Step 2: why is the score low? The Explain API returns how a specific document's score was computed: which clauses matched, the BM25 parts for each term, and boosts. Typical findings: the term matched only a low-weight field; a common term with low IDF; scores from many fields combined in a way that rewards one field over another.

Step 3: later stages. In e-commerce the text score is only the first stage. Re-ranking, personalisation, sponsored placement and business rules can move the product. Check each stage's output for that query; Zalando's public architecture has several such stages above Elasticsearch.

Step 4: fix and protect. Change the analyzer, mapping, field weights or query shape, not k1 and b, which are rarely the cause. Then add the query and its expected results to an offline judgment set, so the fix is measured with NDCG or recall and protected against regressions. See How do you evaluate whether search relevance actually improved?.

  1. Elasticsearch API: Explain a document match resultelastic.co
  2. Elasticsearch docs: Anatomy of an analyzerelastic.co
  3. Lucene 9.11 BM25Similarity Javadoc (defaults and IDF formula)lucene.apache.org
  4. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Search (December 2025)engineering.zalando.com
  5. MICES 2026: Zalando Search: Architecture Behind Product Discovery (video)youtube.com

Canonical answer

2.10 How do you evaluate whether search relevance actually improved?

Without an offline and online evaluation loop, a ranking change is only an anecdote.

The 30 second answer

I never ship on vibe. Offline, I use labeled query–product judgments and metrics like NDCG or recall at a cutoff as a regression suite. Online, I A/B test engagement and revenue while watching for position-bias and UI layout confounds. LLM-as-judge can scale labels if calibrated against humans. I also track zero-result rate and complaint themes. A ranking win that hurts diversity or novelty may still be wrong for the business.

Relevance work without measurement is just rearranging bias.

Offline evaluation. Build a judgment list: for sampled queries, human (or calibrated model) labels of which products are relevant. Relevant Search Ch9–10 describes judgment lists, test-driven relevance, and monitoring clickthrough after deploy. Classic IR metrics: precision/recall, and ranking metrics such as NDCG@k. Zalando's ranking paper reports offline search gains such as Recall@500 and NDCG@500 versus a prior GBT candidate generator — useful as an example of reporting at a retrieval cutoff that matches the ranker input size.

Online evaluation. A/B tests on engagement, conversion, revenue, novelty, and diversity. The same paper reports online browse and search lifts and notes when revenue changes were not significant — cite carefully, do not round into marketing claims. Watch for position bias and UI changes (items per row) that shift clicks and poison training labels; Zalando's MICES talk calls this out in monitoring discussions.

LLM-as-judge. Zalando announced an LLM-as-judge relevance assessment approach (Nov 2024) comparing GPT-class judges on tens of thousands of examples, aiming at cheaper multilingual labeling than pure human labor. Opinion: treat LLM judges as a scalable labeling aid that still needs human calibration and clear guidelines — not as ground truth.

Operational quality metrics. Zero-result rate, reformulation rate, add-to-cart after search, p99 latency, and error budgets. A relevance change that blows the latency SLO is a regression even if NDCG rises.

  1. Relevant Search — measuring relevance (Ch9–10)manning.com
  2. arXiv 2409.02856 — offline/online resultsarxiv.org
  3. Zalando Engineering — LLM-as-a-judge relevance assessment announcementengineering.zalando.com
  4. Microsoft — Experimentation / A/B testing guidance (general methodology reference)microsoft.com

Canonical answer

2.11 What is a Lucene segment, and how do updates, deletes and merges work?

Write-heavy catalog updates create segments, and merges determine both write and read cost.

The 30 second answer

A segment is an immutable mini index written once. Lucene never edits it: an update writes a new version of the document into a new segment and marks the old one deleted in the live-documents bitset. Searches read every segment and skip deleted documents. Background merges combine small segments into larger ones and drop deleted documents, which costs I/O and CPU.

Immutability. Elasticsearch's near-real-time page says that in Lucene, "index" means "a collection of segments plus a commit point", and that a segment is similar to an inverted index. New documents are buffered in memory and written out as a new segment.

Updates and deletes. Because segments are not modified:

  • Delete: mark the document as deleted. Lucene keeps a live-documents file per segment; queries skip deleted documents.
  • Update: delete plus add. The new version goes into a new segment; the old version stays on disk until a merge removes it.

Merges. Many small segments mean more files and more per-segment work per query. A merge policy combines segments of similar size into larger ones in the background, and only then reclaims the space of deleted documents. Merging competes with indexing and search for I/O and CPU, which is why heavy update traffic, such as constant price and stock changes, costs more than the document count suggests.

Why immutability is worth it. Readers need no locks, segment files can stay in the operating system's page cache because they never change, compression can be aggressive, and caches per segment remain valid until the segment is merged away. Introduction to Information Retrieval describes the same idea as a main index plus small auxiliary indexes merged over time.

Practical consequences. Avoid updating the same document many times a second; collapse updates per SKU before indexing. Prefer partial updates only when they save network, since Elasticsearch still reindexes the whole document internally. Force-merge only indices that no longer receive writes.

  1. Elasticsearch docs: Near real-time searchelastic.co
  2. Lucene 9.9 index file formats (segments, term dictionary, postings, live documents)lucene.apache.org
  3. Elasticsearch docs: Merge settingselastic.co
  4. Manning, Raghavan and Schütze: Introduction to Information Retrieval (Cambridge University Press, 2008)nlp.stanford.edu

Canonical answer

2.12 Why is Elasticsearch near-real-time, and how do refresh, flush and indexing latency trade off?

Near-real-time stock and price updates depend on refresh, not only on the index call returning.

The 30 second answer

New documents become searchable only after a refresh opens a new segment, which by default happens every second, but only for indices searched in the last thirty seconds. A flush is about durability: it commits segments to disk and trims the translog. Refreshing more often makes updates visible sooner but creates more small segments and merge work, so bulk loads often relax it.

Refresh. Indexed documents first sit in an in-memory buffer. A refresh writes them into a new segment in the filesystem cache and opens it for search, which is far cheaper than committing to disk. The Elasticsearch docs: by default, it "periodically refreshes indices every second, but only on indices that have received one search request or more in the last 30 seconds." So "always one second" is not quite right, and an index nobody searches may not refresh until the next search arrives.

Flush and the translog. Durability comes from the transaction log: each indexing operation is written to the translog, which can be replayed after a crash. A flush performs a Lucene commit, writing segments durably, and starts a new translog. Refresh controls visibility; flush controls durability and recovery time.

Trade-off. More frequent refreshes make new prices or stock searchable sooner but produce more tiny segments, more merging and less effective caching. The indexing-speed guide recommends bulk requests, a longer refresh_interval during large loads, and temporarily fewer replicas for an initial load, restored afterwards.

Read-your-writes. If a caller must see its own write immediately, use refresh=wait_for on that request rather than forcing refreshes for everyone, and only where it is really needed.

For a catalog. Decide freshness per field: price and stock changes may need seconds, descriptive text can lag. Collapse repeated updates per product before they reach Elasticsearch. Zalando's November 2023 migration post describes intake through the _bulk endpoint and per-index write aliases. How ingestion feeds the index is covered in How do you ingest product updates with CDC or application events?.

  1. Elasticsearch docs: Near real-time searchelastic.co
  2. Elasticsearch docs: Tune for indexing speedelastic.co
  3. Elasticsearch docs: Translogelastic.co
  4. Zalando Engineering: Migrating From Elasticsearch 7.17 to Elasticsearch 8.x (November 2023)engineering.zalando.com

Canonical answer

2.13 How do shards and replicas shape query execution and latency?

Shard count and replica placement set both query parallelism and tail latency.

The 30 second answer

An index is split into primary shards, and each document lives on one of them. A search without routing goes to one copy of every shard, each shard scores locally, and the coordinating node merges the top hits and then fetches the documents. Replicas add read capacity and availability but cost disk and indexing work. Too many small shards adds overhead to every query.

Layout. The Elasticsearch docs: each document belongs to one primary shard, and a replica is a copy of a primary that protects against failure and serves reads. A document's shard is chosen by hashing its routing value, the ID by default.

Query then fetch. A search goes to one copy of each relevant shard. Each shard runs the query, scores locally, and returns its top from + size document IDs and scores. The coordinating node merges them, then fetches the stored fields of the final page from the shards that hold them. Aggregations are reduced on the coordinating node in batches. Zalando's December 2025 post describes this scatter-gather, dedicated coordinating nodes, and adaptive replica selection, which routes each request to the shard copy that has responded fastest recently.

Latency consequences.

  • A query is as slow as its slowest shard, so one hot node raises p99 for everyone.
  • More replicas add read capacity but each still indexes every write.
  • Oversharding adds per-shard overhead to every query and to the cluster state; the shard-sizing guide recommends sizing from data volume and testing, not maximising shard count.
  • Custom routing, for example by market, lets a query hit fewer shards, but can create uneven shards.

Isolation. Zalando's post describes grouping markets into separate clusters so that one market's overload stayed contained, and moving a market to a new cluster using node allocation settings. Separate clusters or indices per market group trade operational cost for blast-radius control.

Guardrails. search.max_buckets limits aggregation buckets and index.max_result_window limits deep pagination. Zalando's incident was caused by an internal client sending far more high-cardinality aggregation queries than usual; its fixes included per-client slow logs via the X-Opaque-Id header and per-client limits. More on that in How do you keep search available when queries or updates overload the cluster?.

  1. Elasticsearch docs: Clusters, nodes and shardselastic.co
  2. Elasticsearch docs: Search shard routing and adaptive replica selectionelastic.co
  3. Elasticsearch docs: Size your shardselastic.co
  4. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Search (December 2025)engineering.zalando.com

Canonical answer

2.14 How much heap should an Elasticsearch node get, and why does the page cache matter?

Elasticsearch needs the page cache for terms and doc values; oversizing the heap takes memory away from it.

The 30 second answer

Less than you might think. Elastic's guidance is to set the minimum and maximum heap equal, at no more than half the node's memory, and below the compressed object pointer threshold, which is about 26 gigabytes on most systems. Lucene reads its immutable segment files through memory mapping, so the rest of the memory serves as the operating system's file cache, which is what keeps searches fast.

The guidance. Elasticsearch's JVM settings page says the default sizing suits most production clusters. To override it, set Xms and Xmx to the same value, no more than 50% of the memory available to the node, and below the threshold for compressed ordinary object pointers: "26GB is safe on most systems and can be as large as 30GB on some." The startup log reports whether compressed pointers are in use.

Why not give it all to the heap. Elasticsearch also uses memory outside the heap, for example network buffers, and the search-speed guide says it "relies heavily on the filesystem cache", recommending that at least half the available memory goes to it so hot parts of the index stay in RAM. Because segments are immutable, cached file pages stay valid until a merge replaces the segment.

Memory mapping. Elasticsearch stores indices with a memory-mapped directory by default, so Linux must allow enough mappings: the virtual memory setting vm.max_map_count has to be raised above the operating system default, as the install docs describe.

JVM view. A larger heap means more live data for the collector to trace and potentially longer pauses, and above the compressed-pointer threshold every reference grows from 4 to 8 bytes, so a bigger heap can hold fewer objects. Watch heap after GC, GC pause times, and the circuit breakers that reject requests which would use too much memory.

What fills the heap. Field data on text fields, which should be avoided, large aggregations, big bulk requests, and caches. High-cardinality terms aggregations, as in Zalando's incident, are both CPU-heavy and memory-heavy.

The general JVM side is on What are the JVM memory areas? and How do G1 and ZGC compare?.

  1. Elasticsearch docs: JVM settings (heap size)elastic.co
  2. Elasticsearch docs: Tune for search speed (filesystem cache)elastic.co
  3. Elasticsearch docs: Virtual memory (vm.max_map_count)elastic.co
  4. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Search (December 2025)engineering.zalando.com

Canonical answer

2.15 How do you train a two-tower retrieval model with in-batch and hard negatives?

Neural retrieval on top of Elasticsearch starts with how the two-tower model is trained.

The 30 second answer

One tower encodes the query side, such as the cart, and one encodes candidate products into the same space, so their dot product is the score. I hold one item out of each completed order as the positive, use other items in the batch as negatives, and train with InfoNCE. I add periodically mined hard negatives, correct for popularity bias and split evaluation by time.

Towers. Instead of lookup rows, each side is a network that turns features into a vector. The cart tower reads the items in the cart and maybe recent history; the product tower reads title, category, brand and price. Both output the same dimension, and the loss only sees their dot product, so that score means "how likely this product completes this cart". Because towers compute vectors, unseen carts and new products get embeddings.

Positives. Take completed orders and hold one item out. The rest of the cart is the query and the held-out item is the positive.

In-batch negatives and InfoNCE. Score every cart in a batch against every product. The diagonal holds true pairs; off-diagonal products are free negatives. InfoNCE treats each row as a classification: a softmax over the row and the negative log-probability of the diagonal item, which is ordinary cross-entropy. Larger batches give more negatives. In-batch sampling over-represents popular items as negatives; Yi et al. correct the batch loss for this sampling bias, using item frequencies estimated from streaming data.

# Verified with Python 3 and NumPy 2.5 on 2026-11-09.
import numpy as np

def info_nce(cart_vecs, item_vecs, temperature=0.05):
    # Row i of cart_vecs pairs with row i of item_vecs; other rows are in-batch negatives.
    q = cart_vecs / np.linalg.norm(cart_vecs, axis=1, keepdims=True)
    k = item_vecs / np.linalg.norm(item_vecs, axis=1, keepdims=True)
    logits = q @ k.T / temperature               # B x B cosine scores
    logits -= logits.max(axis=1, keepdims=True)  # numerical stability
    log_probs = logits - np.log(np.exp(logits).sum(axis=1, keepdims=True))
    return -np.mean(np.diag(log_probs))          # cross-entropy, target = diagonal

Correctly paired carts give a loss near zero; shuffling the pairs makes it large.

Hard negatives. Random negatives soon become easy and give almost no gradient, so training loss flattens while retrieval stays weak. Hard negatives, items the current model ranks highly but the shopper did not buy, force fine distinctions. Finding them needs a search with the current model, so teams periodically snapshot it, re-embed the catalog and mine neighbours from that index; ANCE formalizes this with an asynchronously refreshed ANN index. The very top neighbours are often unlabeled true positives, so skip or down-weight them, as RocketQA's denoised hard negatives do.

A complete answer. Say what a positive is, where negatives come from, and that evaluation uses orders placed after the training window.

Alternate interview scenarios

"Why use InfoNCE rather than triplet loss?"

Triplet loss takes an anchor, a positive and a negative and asks the positive to be closer than the negative by at least a margin. Once a triplet satisfies the margin its loss and gradient are zero, so training effort moves to triplets that are still wrong. That is intuitive, but it needs careful triplet mining; FaceNet used online mining for that reason. InfoNCE compares each positive against every other item in the batch at once, so with large batches it gets many negatives for free. I would pick InfoNCE for large-batch retrieval training and keep triplet loss as a reasonable alternative when batches are small or data naturally comes as triplets.

"How would you build reverse image search without click labels?"

Make positives by augmentation. Crop an image slightly, recompress it, shift colours or rotate it, and treat each version as a positive for the original, with other images in the batch as negatives. The model learns to ignore those distortions, so a blurry screenshot lands next to the catalog photo. When both sides are the same kind of input, one shared encoder serves both sides, which is a Siamese setup, the structure Sentence-BERT uses for text.

  1. van den Oord et al., Contrastive Predictive Coding (InfoNCE)arxiv.org
  2. Yi et al., Sampling-Bias-Corrected Neural Modeling for Large Corpus Item Recommendationsresearch.google
  3. Karpukhin et al., Dense Passage Retrieval (in-batch negatives)arxiv.org
  4. Xiong et al., ANCE: negatives from an asynchronously updated ANN indexarxiv.org
  5. Qu et al., RocketQA: denoised hard negativesarxiv.org
  6. Schroff et al., FaceNet (triplet loss and online triplet mining)arxiv.org
  7. Reimers and Gurevych, Sentence-BERT (Siamese networks)arxiv.org
  8. Sentence Transformers docs: losses (MultipleNegativesRankingLoss, TripletLoss)sbert.net

Canonical answer

2.16 Why does a term query on a text field miss documents, and how do multi-fields fix it?

The 30 second answer

A text field is analyzed at index time, so "Red Dress" is stored as the tokens red and dress. A term query is not analyzed, so it looks for the exact string Red Dress and finds nothing. Use match for full text, and add a keyword sub-field through multi-fields for exact filters, sorting and facets. That gives one source value indexed two ways, each for its own job.

This is one of the most common Elasticsearch mistakes, and it shows up again in the facet question below, because facets need the same exact values.

Why it misses. The official docs warn: "Avoid using the term query for text fields." A text field goes through an analyzer at index time. The default standard analyzer removes most punctuation, splits on word boundaries and lowercases, so the brand value "Tommy Hilfiger" is indexed as the two terms tommy and hilfiger. A term query is not analyzed. Searching term: { brand: "Tommy Hilfiger" } looks for one term with a capital T and a space in it, which does not exist in the index, so it returns nothing. term: { brand: "tommy" } would match, which is even more confusing in a code review.

Match versus term. A match query runs the query text through the field's search analyzer first, so match: { brand: "Tommy Hilfiger" } produces the same tokens and matches. Use match (and its relatives such as multi_match) for what a shopper types, and term, terms or range for exact, structured values such as an id, a size code or a flag.

Multi-fields. Most product attributes need both behaviours: brand must be searchable as words and also filterable, sortable and aggregatable as an exact value. Multi-fields index the same source value more than once:

"brand": {
  "type": "text",
  "fields": {
    "raw": { "type": "keyword" }
  }
}

Queries use brand for full text and brand.raw for term filters, sorting and terms aggregations. A keyword field is indexed as one unanalyzed term and has doc values, which is what sorting and aggregations read. If case differences in the source are a problem, a normalizer on the keyword field lowercases it without splitting it.

Why the dynamic default matters. With dynamic mapping, Elasticsearch typically maps a new string as text with a keyword sub-field, which is why field.keyword appears in so many examples. In a production catalog you want explicit mappings instead, so analyzers per language and keyword sizes are deliberate and a stray field cannot cause a mapping explosion.

How to debug it live. Run the _analyze API with the field's analyzer on the stored value and on the query text, then compare tokens. If the tokens differ, the query type or the mapping is wrong.

Alternate interview scenarios

"A filter on colour works for most values but not for 'Light Blue'. Why?"

Most colours are single words, so the analyzed tokens happen to equal the lowercased value and a term filter on lowercase input appears to work. "Light Blue" becomes two tokens, so an exact match on the whole value can never succeed against the text field. The fix is to filter on a keyword sub-field, with a normalizer if the data has inconsistent casing.

"Can you aggregate on a text field?"

Not by default, because text fields do not have doc values; aggregations and sorting need a keyword (or numeric) field. Enabling fielddata on a text field makes it possible but loads the analyzed terms into heap, which is expensive and aggregates on tokens rather than whole values. Use a keyword sub-field instead.

  1. Elasticsearch: Term queryelastic.co
  2. Elasticsearch: Multi-fieldselastic.co
  3. Elasticsearch: Keyword field typeelastic.co

Open as a standalone page

2.17 Should product variants be nested, parent-child or flattened documents?

The 30 second answer

My default is to denormalize. One document per product, or per sellable variant, with the attributes queries need copied in. I use nested only when an array of objects must be matched element by element, such as size together with stock. Parent-child fits when children vastly outnumber parents and change often, like offers on products, and it costs query speed and same-shard routing. Flattened is for many arbitrary keys you only filter on.

A fashion catalog has a product with many variants (colour and size) and often many offers (price and stock per merchant or warehouse). How you put that into Elasticsearch decides both correctness and speed.

Option one: denormalize. Elasticsearch's own guidance on joins is that "the key to good performance is to de-normalize your data into documents". You either index one document per product with the variant facts it needs flattened in, or one document per sellable variant (colour), with sizes as a multi-valued field. Queries are fast and simple. The cost is write amplification: one stock change rewrites a whole document, and in Lucene an update is a delete plus a new document.

Option two: nested. A plain array of objects is flattened into parallel arrays, so the document {sizes: [{size: "M", inStock: false}, {size: "L", inStock: true}]} would match a query for "size M and in stock", because M and true both appear somewhere. The nested type indexes each array element as a separate hidden document so a nested query can require both conditions on the same element. The docs call nested documents and queries "typically expensive" and guard them with limits: index.mapping.nested_fields.limit (default 100 distinct nested mappings) and index.mapping.nested_objects.limit (default 10,000 nested objects per document). Because an update in Elasticsearch rewrites the whole document, changing one variant also rewrites the parent and every hidden nested document with it.

**Option three: parent-child (the join field).** Parent and child are separate documents in the same index, so a child can be updated without touching the parent. The docs restrict it: only one join field per index, parent and children must be routed to the same shard, and each has_child or has_parent query "adds a significant tax" to query performance and can trigger global ordinals to be built. The docs give exactly the e-commerce example: the join makes sense when one entity significantly outnumbers the other, such as products and offers, with the product as parent and offers as children.

Option four: flattened. The flattened type maps an entire object as a single field, indexing leaf values as keywords. It protects you from a mapping explosion when attributes have many arbitrary keys (supplier-specific attributes, say), but every value is a keyword, so there is no full-text analysis or numeric range semantics on the contents.

How I decide.

  1. Does the query need per-element correlation (size and stock on the same variant)? If not, plain fields are enough.
  2. How often do the child facts change compared with the parent? Very frequent stock or price churn on a large parent argues for splitting documents, or for keeping volatile facts out of the search document and checking them at read time.
  3. What is the latency budget? Nested and join queries cost more on every request, so the budget often decides for denormalization plus a fast update path.

Alternate interview scenarios

"Stock for sizes changes thousands of times a minute. Where should it live?"

I would not reindex a heavy product document on every stock tick. Options: index only an in-stock flag or available sizes and update it on a separate fast lane with coalescing, so many ticks collapse into one write; or keep exact stock out of the index and re-check it at add-to-cart. Parent-child is possible because offers can be updated alone, but every query then pays the join cost, so I would measure before choosing it.

"Why does my nested filter return products that do not match?"

Usually the field was mapped as a plain object, not nested, so the conditions are matched across different array elements. Or the query was written as two separate nested clauses instead of one nested query with both conditions inside, which again allows the conditions to match different elements.

  1. Elasticsearch: Nested field typeelastic.co
  2. Elasticsearch: Join field typeelastic.co
  3. Elasticsearch: Flattened field typeelastic.co

Open as a standalone page

2.18 How do you index a write-heavy catalog without hurting search latency?

The 30 second answer

I batch writes with the bulk API and benchmark the batch size, coalesce repeated updates per product before they reach the cluster, and keep refresh at the freshness the product needs, not lower. For full rebuilds I write to a new index with replicas off, then add replicas and swap an alias. I watch indexing and search thread-pool rejections, merges and refresh time, and throttle ingestion before search suffers.

A large catalog receives price, stock and attribute changes continuously, and the same cluster serves shoppers. The question is really how to keep writes from starving reads. Public candidate reviews from other companies ask the same thing as "what if the system is write-heavy?".

Reduce the work before it reaches Elasticsearch.

  • Coalesce. If one product changes five times in a second, only the last state matters for search. Key the update stream by product id and keep only the latest version per short window, or compare against the last indexed version and skip no-op updates. Every skipped write is a skipped Lucene delete-and-add.
  • Separate lanes. Volatile fields (price, stock) and slow fields (descriptions, attributes) can be updated on different paths, so a burst of description edits cannot delay a price change.
  • Version the writes. With at-least-once delivery, events arrive late or twice. External versioning or a timestamp check makes an older event a no-op instead of overwriting newer data.

Make each write cheaper.

  • Bulk. The docs are direct: "Bulk requests will yield much better performance than single-document index requests." They recommend finding the batch size by benchmark, starting at 100 documents and doubling, rather than guessing a magic number.
  • Refresh interval. A refresh makes changes visible and is costly. The default refreshes every second, and the docs note it only does so on indices that have received a search request recently. A catalog that can tolerate a few seconds of lag can raise refresh_interval; one that promises near-instant price updates cannot, so this is a product decision.
  • Initial loads and full rebuilds. For a bulk load into a new index, the docs suggest setting number_of_replicas to 0 and restoring it afterwards, because the data can be reloaded if a node fails during the load. Combined with an alias swap, this is the standard zero-downtime rebuild.
  • Document ids. Explicit ids force a per-shard check that a document with the same id exists. A catalog needs explicit ids to update in place, so accept that cost and save elsewhere.

Protect search.

  • Watch write and search thread-pool queues and rejections, merge activity and refresh time per index.
  • Throttle or pause ingestion when search latency degrades. Zalando's public outage write-up below lists stopping ingestion as one of the steps that gave its cluster room to recover.
  • Size shards deliberately: too few primaries cap write parallelism; too many small shards waste heap and coordination.

Alternate interview scenarios

"A nightly full reindex makes daytime search slow because it overruns. What do you change?"

Build into a separate new index, ideally on separate nodes or a separate cluster, with replicas at 0 and a relaxed refresh during the load, then add replicas, warm it and swap the alias atomically. Cap the bulk concurrency so the live index keeps its share of disk and CPU, and resume incremental updates from the point the rebuild snapshot was taken so nothing is lost.

"Writes are rejected with 429s during a sale. What does that tell you?"

The write thread pool's queue is full, so the nodes cannot keep up with the indexing rate. Clients should retry with backoff, not hammer. Then I look for the cause: hot shards because of uneven routing, too-small bulk requests, a refresh interval that is too aggressive, or merges falling behind on slow disks.

  1. Elasticsearch: Tune for indexing speedelastic.co
  2. Elasticsearch API: Bulkelastic.co
  3. Elasticsearch: Size your shardselastic.co
  4. Elasticsearch: Thread pool settingselastic.co

Open as a standalone page

2.19 How does Elasticsearch avoid split brain, and why use dedicated master nodes?

The 30 second answer

Split brain means two halves of a partitioned cluster both elect a master and accept conflicting changes. Elasticsearch prevents it with quorums: a master election or cluster-state change needs responses from more than half of the voting configuration. From version seven the cluster manages that voting set itself. Dedicated masters keep elections and cluster-state updates away from heavy search and indexing load, so a busy data node does not destabilise the whole cluster.

The problem. A cluster has one elected master that owns the cluster state: which indices exist, their mappings and where every shard lives. If a network partition splits the nodes and each side elected its own master, both sides could accept writes and change the state independently. Reconciling them later would lose data. That is split brain.

The mechanism. The docs explain that decisions such as electing a master or committing a new cluster state are made "only after more than half of the nodes in the voting configuration have responded". In a partition at most one side can hold a majority, so at most one side can elect a master; the minority side cannot make progress. The quorums "are carefully chosen so the cluster does not have a split brain scenario".

What changed in version seven. Older clusters relied on an operator setting discovery.zen.minimum_master_nodes to a majority by hand, and a wrong value after adding nodes made split brain possible. From 7.0 that setting is permitted but ignored; Elasticsearch maintains the voting configuration itself as master-eligible nodes join and leave. Two operational rules remain: do not stop half or more of the voting nodes at once, and use the voting config exclusions API before removing many master-eligible nodes deliberately.

Why dedicated masters. The master does lightweight but critical work: elections, cluster-state publication and shard allocation decisions. If master-eligible nodes also run heavy searches and aggregations, a long GC pause or CPU saturation on a data node can delay cluster-state updates or trigger re-elections, which then triggers shard movement and makes the overload worse. Three small dedicated master-eligible nodes give a quorum that survives one failure and stays isolated from query load. The same isolation idea is why Zalando's outage post mentions dedicated coordinator nodes.

Upgrade order. Elastic's upgrade guidance upgrades data nodes first and master-eligible nodes last, because older nodes cannot always join a cluster with an upgraded master. Zalando's upgrade post mentions adding a human approval step so masters are not upgraded before data nodes.

Alternate interview scenarios

"Why not run two master-eligible nodes?"

With two voting nodes a majority is both of them, so losing either one stops the cluster from electing a master. Two gives no more fault tolerance than one. Three tolerates one failure, which is why three is the usual minimum for production.

"The cluster is red after a network blip and a node rejoined. What do you check first?"

Cluster health and the allocation explain API to see which shards are unassigned and why, the master node's logs for election and cluster-state publication timeouts, and whether long GC pauses on master-eligible nodes caused them to drop out. If masters share hardware with heavy data work, that is the structural fix.

  1. Elasticsearch: Quorum-based decision makingelastic.co
  2. Elasticsearch 7.0 breaking changes: discovery changeselastic.co
  3. Elasticsearch: Upgrade an Elasticsearch cluster (node upgrade order)elastic.co
  4. Zalando Engineering: Migrating From Elasticsearch 7.17 to Elasticsearch 8.xengineering.zalando.com

Open as a standalone page

2.20 When is exact kNN still the right choice in Elasticsearch, and what does approximate kNN cost?

The 30 second answer

Approximate kNN uses a graph index such as HNSW to find likely neighbours without scoring every document, so latency stays low on large sets. The cost is slower, memory-hungry indexing and recall below a hundred percent. Exact kNN scores every matching document, so it is precise but only fast when a filter first narrows the set. I tune candidates per shard against measured recall, and filter inside the kNN search.

Zalando's public Elasticsearch upgrade post gives this as the main product reason for moving from 7.17 to 8.x: the team already used vector search, but only the exact, brute-force kind, and wanted approximate kNN. That makes the trade-off a natural interview topic.

Exact kNN. A script_score query (or the exact dense_vector query) computes the vector similarity for every document that matches the surrounding query. Elastic's docs say it "guarantees accurate results but does not scale well for large datasets", and that latency improves if a filter narrows the set first. So exact kNN is right when the candidate set is already small: one merchant's assortment, a single category after strict filters, a re-ranking step over a few hundred lexical hits, or an evaluation job that needs the true neighbours as ground truth.

Approximate kNN. The top-level knn search uses a vector index (HNSW graphs, optionally with quantization) to visit only a fraction of the vectors. Elastic recommends it for most production workloads "where low latency and scale matter more than perfect recall". The costs:

  • Indexing. The dense_vector docs call indexing vectors for approximate search "an expensive process" that can take substantial time; every segment builds its own graph, and merges rebuild them.
  • Memory. The graphs want to live in the OS page cache for good latency, which competes with everything else on the node. Quantization reduces that footprint at some cost in precision. Which index type and quantization you get by default depends on the Elasticsearch version and the vector element type, so check the version's dense_vector docs rather than assuming.
  • Recall. Results are approximate. k is the number of neighbours returned, and num_candidates is "the number of nearest neighbor candidates to consider per shard". Raising num_candidates improves recall and costs latency.

Filtering. Put structured filters (category, size, in stock, market) in the kNN search's filter option so the search looks for the nearest neighbours among documents that pass the filter. Filtering the top results afterwards can leave fewer than k hits or none at all.

Hybrid retrieval. In product search, vectors usually complement lexical matching rather than replace it: exact brand and size tokens matter. Combine a lexical query and a knn leg, and fuse with a rank-based method or a weighted score, then evaluate offline before shipping.

How to tune honestly. Compute exact neighbours for a sample of real queries with brute force, then measure the recall of approximate search at several num_candidates values against latency. Pick the knee of the curve, not a number from a blog post.

Alternate interview scenarios

"Vector search returns nothing for some filtered queries. Why?"

If the filter is applied after an approximate search, the top candidates may all be filtered out. Move the filter into the kNN search, and check that the filter is not so selective that a brute-force search over the remaining documents would be cheaper; for very small filtered sets, exact kNN is the better tool.

"A new embedding model is ready. How do you roll it out?"

Vectors from two models are not comparable, so I index the new vectors into a new field or a new index, backfill the whole catalog, compare offline and in an A/B test, and switch reads with an alias or a query flag. Mixing old and new vectors in one field during a rollout would silently corrupt similarity.

  1. Elasticsearch: kNN searchelastic.co
  2. Elasticsearch: Dense vector field typeelastic.co
  3. Elasticsearch API: Search (knn options)elastic.co
  4. Zalando Engineering: Migrating From Elasticsearch 7.17 to Elasticsearch 8.xengineering.zalando.com

Open as a standalone page

2.22 Why might a team choose a blue/green cluster over a rolling major Elasticsearch upgrade?

The 30 second answer

A rolling upgrade is Elastic's usual path, but on a large live catalog it means a long mixed-version window with relocating shards, and if it fails, recovery is a snapshot restore plus replaying data on a cluster customers depend on. A parallel cluster lets you restore a snapshot, shadow writes and queries, compare, and shift traffic gradually, with rollback by routing. Zalando's public upgrade post describes this choice.

Zalando's engineering blog describes how its search department moved Elasticsearch from 7.17 to 8.x. It is a strong source for questions about upgrades, data synchronisation and client migration.

What Elastic recommends. To reach 8.x from an older 7.x you first upgrade to 7.17, use the Upgrade Assistant to resolve critical issues, and then do a rolling upgrade (data nodes first, master-eligible nodes last). The alternative is a new cluster and copying data across with reindexing.

Why the post chose a parallel cluster. The clusters were large, holding "some terabytes" across a multi-cluster deployment, so a rolling upgrade meant a long degraded window with mixed versions and relocating shards. The real fear was failure: data loss would force a restore from snapshots and a reset of the input streams for every index, during which the catalog would be unavailable or stale. With a second cluster, cutover and rollback become routing changes, and both clusters can be compared side by side.

The procedure the post lists, per cluster.

  1. Deploy the 8.x cluster and its monitoring, and create index templates.
  2. Restore the latest snapshot.
  3. Shadow the intake traffic, the bulk and write-alias requests, to the new cluster, using the Skipper ingress's traffic-teeing filters. Template and mapping requests were shadowed too, because templates live in code and are applied on application restart.
  4. Close the gap between snapshot time and the moment shadowing began by resetting the data streams to just before the snapshot.
  5. Shadow query traffic gradually and compare latency, errors and index sizes on dashboards.
  6. Shift live traffic in percentages, then clean up the old cluster.

Client migration. The main search application was Scala code using the Java High Level REST Client, deprecated since 7.15. The team chose the official Elasticsearch Java API Client over a popular community Scala client, mainly because the official one follows Elastic's releases and already had a DSL for kNN, which was the feature they were upgrading for. They started with the old client in REST API compatibility mode so clusters could move first, and tested three combinations with Testcontainers: new client with old cluster, old client with new cluster, and new client with new cluster.

Breaking changes the post hit. _type disappearing from search responses, is_write on an alias no longer accepting null, numeric bounds on date range queries needing to be strings, action.destructive_requires_name now defaulting to true (which broke tests that deleted indices by wildcard, so it was relaxed only in tests), and sorts on nested fields failing unless nested.path was set. Treat these as that codebase's experience, not a complete list.

The incident it reports. A routing error during one country's cutover created an extra index behind the same alias, so products appeared twice across that country until the stray index was dropped.

Alternate interview scenarios

"After restoring a snapshot onto the new cluster, how do you keep it in sync?"

A snapshot is a point in time, so the new cluster misses everything between the snapshot and the moment it starts receiving writes. Shadow live writes first, then replay the source streams from just before the snapshot, relying on idempotent, versioned writes so replayed events cannot overwrite newer ones. Watch document counts and index sizes on both clusters until they converge, then shadow reads.

"How do you test a period where some clusters run 7.x and others 8.x?"

Enable REST API compatibility on the 7.x High Level REST Client, which the docs say sends headers asking 8.x to honour 7.x requests and responses, and move the client later. Compatibility only bridges one major version, so it is a transition tool. Run the important query shapes against both versions in containers, including the features that justify the upgrade.

"How do you change a mapping on a live index without downtime?"

Most changes to an existing field's mapping cannot be made in place, so create a new index with the new mapping, reindex or replay into it, then move the alias in a single _aliases request with a remove and an add action, which the docs describe as atomic: the alias never points to both indices. If an alias does point to several indices, writes need an explicit is_write_index, and reads see both, which is how duplicates appear.

"Two indices are behind one alias and shoppers see duplicate products. What do you do?"

Confirm with the get-alias API which indices the alias resolves to, remove the stray index from the alias in one atomic _aliases call (or drop it, as the post's team did), and check document counts against the source. Afterwards, add a pre-cutover check that every alias resolves to exactly one index.

  1. Zalando Engineering: Migrating From Elasticsearch 7.17 to Elasticsearch 8.x: Pitfalls and Learningsengineering.zalando.com
  2. Elasticsearch 8.19: Upgrade Elasticsearchelastic.co
  3. Elasticsearch: Upgrade an Elasticsearch cluster (node upgrade order)elastic.co
  4. Elasticsearch Java REST Client 7.17: Compatibility with Elasticsearch 8.xelastic.co
  5. Elasticsearch 8.19: REST API compatibilityelastic.co
  6. Elasticsearch: Aliaseselastic.co
  7. Elasticsearch API: Reindexelastic.co

Open as a standalone page

Search system design

The layer in front of Elasticsearch, event delivery with Nakadi, stream enrichment state in Flink, and designing for billions of records.

After the index itself comes the layer in front of it: a product-search design, how catalog updates and events stay consistent, and then the company-specific pieces, a search API layer, Nakadi, Flink enrichment state, and a design that has to handle billions of records.

3.1 What requirements and trade-offs define a product-catalog search system?

A catalog search design starts from requirements and trade-offs, not from a cluster diagram.

The 30 second answer

I separate discovery from commerce writes. Search must be fast, highly available, multilingual, and good enough that empty results are rare, while price and stock stay eventually consistent. Booking-style strong consistency belongs to checkout, not the search index. The hard trade-offs are freshness versus indexing cost, precision versus recall, and personalization versus a strict latency budget. I would state SLOs for p99 latency, index lag, and relevance quality up front.

Product search has two jobs that pull in opposite directions.

Functional. Users type free text, apply filters (brand, size, color, price), browse categories, and expect facets that update with the current result set. Merchants and partners change title, attributes, price, and stock continuously. Sponsored or promoted items may be blended with organic results. Some platforms also expose the same retrieval backend to assistants or conversational entry points.

Non-functional. Discovery traffic is read-heavy and latency-sensitive. Availability matters more than perfect freshness for most listing fields, but stale price or stock that looks "buyable" creates bad UX and support load. Multilingual catalogs need language-aware analysis. Peak events (sales weeks) amplify both query QPS and update rate.

Core trade-offs to name in the interview

  1. Freshness vs cost. Indexing every attribute change immediately is expensive; batching hurts price/stock truthfulness.
  2. Precision vs recall. Strict matching looks "right" but creates zero-result pages; loose matching fills the page with weak hits. Relevant Search treats matching and ranking as separate steps and argues you should never leave the user on an empty page — relax, spell-correct, or show possibly relevant results and tell the UI what you did (Ch8).
  3. Personalization vs latency. Extra model hops improve engagement but consume the p99 budget; production systems often skip enrichment steps when the budget is gone.
  4. Strong consistency vs search scale. Seat inventory or payment ledgers need strong writes; a search replica of the catalog should not be the source of truth for checkout.

Opinion: in an interview, put hard SLOs on the whiteboard early: end-to-end search p99, maximum acceptable index lag for price/stock, and how you will measure relevance (offline judgments + online A/B). Without those, every later choice is hand-waving.

Engineering lesson (public Zalando sources). Zalando's Search & Browse path uses Elasticsearch for base candidates (lexical and vector), a query-understanding / NER layer, a catalog/presentation layer that can fan out queries and own redirects/facets, and a later re-ranking / algorithm layer with personalization and promotions. Their Dec 2025 incident post describes that layered query path and per-layer caching; their arXiv ranking platform paper reports a 500 ms end-to-end SLO with roughly 200 ms observed for the personalized ranking path in that system. Treat those as published reference points for one large fashion marketplace, not universal constants.

  1. Relevant Search — Ch8 relevance feedback / empty results (synthesis from Ch8; empty-result guidance around the book's "never show an empty page" discussion)manning.com
  2. Designing Data-Intensive Applications (early-release notes) — full-text search / inverted indexes (Ch3 discussion of inverted indexes; see also local study map theme 1)dataintensive.net
  3. Zalando Engineering — The Day Our Own Queries DoS'ed Usengineering.zalando.com
  4. arXiv 2409.02856 — Building a Scalable, Effective, and Steerable Search and Ranking Platformarxiv.org

Canonical answer

3.3 How do you ingest product updates with CDC or application events?

Stock, price and catalog changes have to reach the index reliably, whether from CDC or from application events.

The 30 second answer

I prefer change events over polling. CDC from the database or a transactional outbox gives at-least-once delivery into a bus, then a stream job joins product, price, and stock into one search document. Thin events that only carry an id force a re-fetch; fat events risk fan-out of huge payloads. Idempotent consumers, poison-event handling, and a scheduled backfill are mandatory. I would ask which fields must be near real time before sizing the pipeline.

Goal. Turn every relevant catalog change into an updated search document without dual-writing from every service.

Two common producers

  1. Application events / outbox. The service that commits the product write also writes an outbox row in the same transaction; a relay publishes to the bus. Pros: business-meaningful events. Cons: every writer must participate.
  2. CDC (logical replication). A platform tails Postgres (or similar) and emits row-level changes. Zalando has publicly described Fabric Event Streams: embedded Debezium micro-apps, CRDs for source/flow/sink, sinks to Nakadi event types or SQS, supporting outbox-style flows. That is a company-wide CDC platform; public posts do not claim it is wired directly into search, so in an interview phrase it as a pattern, not as a private fact.

Bus shape. Many orgs put Kafka underneath. Zalando's open Nakadi project is a REST abstraction over Kafka with event types, schemas, at-least-once delivery, and cursor commits — useful as a public example of "protect the cluster, enforce schema, authorize per event type." Weakness to mention: a bad event can stall a partition cursor unless DLQ / skip policies exist (Nakadi added auto-DLQ / unprocessable-event policies in 2023).

Join / enrichment. Price, stock, product attributes, and boost/sponsored metadata rarely arrive as one message. A stream processor (often Flink) keys by SKU and maintains join state. Zalando's Mar 2026 Flink post describes replacing chained Table API joins with a union + keyed process function and a single value state, cutting state roughly 240 GB → 56 GB for product-offer enrichment that feeds the catalog. Opinion: that post is about offer enrichment, not a full end-to-end search ingest diagram — say so if you cite it.

Thin vs fat events (Netflix contrast). Netflix Studio Search has described thin CDC/application events (id only), then re-fetching the full entity through GraphQL before Elasticsearch. Cost sits on source services. Keeping rich state in Flink moves cost to operator state size. Pick explicitly.

Hardening. At-least-once ⇒ idempotent upserts by document id / version. Poison messages need DLQ. Retain a full reindex / snapshot restore path for schema changes and disaster recovery.

  1. Debezium documentationdebezium.io
  2. Zalando Engineering — Contributing to Debeziumengineering.zalando.com
  3. Zalando Engineering — Why We Ditched Flink Table API Joinsengineering.zalando.com
  4. Nakadi documentationnakadi.io
  5. Netflix TechBlog — GraphQL search indexingnetflixtechblog.com

Canonical answer

3.4 How should the search index be designed for a multi-market catalog?

A European fashion catalog is queried by market, language and availability, which the index layout has to reflect.

The 30 second answer

I shard by document id inside each index and usually isolate markets or market groups so one region's load cannot take others down. Writes go through aliases so reindex and blue-green upgrades are atomic. Primaries are sized for growth; replicas buy read capacity and availability. Analyzers are per language. I call out over-sharding and high-cardinality aggregations as self-inflicted outage risks.

Document partitioning. Elasticsearch (and Lucene-based engines generally) typically use local secondary indexes: each shard holds a subset of documents and its own inverted index. A query fans out to relevant shards and the coordinating node merges hits. DDIA contrasts local vs global secondary indexes: local makes writes cheap and reads scatter/gather; global makes term lookups cheaper but complicates writes.

Market isolation (Opinion + public lesson). For multi-country catalogs, prefer separate indices (or clusters) per market or market group behind aliases. Zalando's ES 8 migration post describes country indices behind aliases, write aliases (_write), numeric suffix index names, and multi-cluster deployment. Their Dec 2025 incident post notes market-grouping isolation containing blast radius when one cluster was overloaded. The 2022 Elasticsearch Berlin meetup talk (Girish Chandrashekar) described markets in separate indices and hardware profiles grouped by traffic — treat 2022 sizing numbers as historical, not current capacity planning.

Aliases and blue/green. Clients should talk to aliases, not raw index names, so you can reindex and swap. Zalando's ES 7→8 migration used blue/green via reindex/snapshot, shadowed intake and query traffic (Skipper), then gradual cutover — with a routing bug that briefly left two indices behind one alias and duplicated products. Mention aliases as both a feature and an operational footgun.

Vectors. If you store embeddings for ANN, plan for refeed on model change. Zalando's arXiv ranking paper states new item embeddings go through a Kafka intake into Elasticsearch and that a new model needs a full refeed with blue-green switch of embeddings.

What not to invent. Public Zalando posts do not document production primary/replica counts for core search. Do not invent shard counts; explain the sizing method instead (target shard size, heap, query fan-out, refresh cost).

  1. Elasticsearch — Index aliaseselastic.co
  2. Elasticsearch — Scalability and resilience (and related sizing guides)elastic.co
  3. DDIA — sharding with secondary indexes (study map theme 4)dataintensive.net
  4. Zalando Engineering — Migrating From Elasticsearch 7.17 to 8.xengineering.zalando.com
  5. Zalando Engineering — The Day Our Own Queries DoS'ed Usengineering.zalando.com
  6. arXiv 2409.02856arxiv.org

Canonical answer

3.5 Walk through an end-to-end product search query pipeline

You need a clear picture of the path from the search box to ranked hits, including recall, ranking and presentation.

The 30 second answer

First I clean and understand the query: tokenize, spell-correct, detect entities like brand or category, and decide redirects. Then base search retrieves a candidate set with lexical matching, filters, and optionally vectors. A later stage re-ranks with business rules and models, and may blend promotions. Facets are a separate aggregation-shaped call. Every optional enrichment sits behind a latency budget with a simpler fallback.

A strong interview answer draws a pipeline, not a single Elasticsearch query.

1. Query understanding. Normalize text (tokenize, casing, banned terms, spell correction). Run named-entity recognition so "Nike red trainers" becomes structured filters / boosted fields (brand=Nike, color=red, category≈shoes). Classify intents and redirects (bare brand → brand landing page). If nothing maps, mark the query as unconstrained and lean on semantic / embedding retrieval.

Public MICES 2026 talk notes (Zalando Search: Architecture Behind Product Discovery) describe standardization → dictionary NER + LLM NER → classification/redirects → enrichment → unmapped fallbacks, and a goal of avoiding empty result pages for real words.

2. Base retrieval (candidate generation). Elasticsearch (or similar) returns an initial set using lexical queries (BM25-style scoring over analyzed fields), structured filters, and optionally approximate kNN over product embeddings. Filters should be true filters (no scoring) for cacheability. Hybrid retrieval combines lexical and vector legs; fusion can be score-based or set-based (Opinion: start with a clear lexical baseline before adding ANN).

3. Presentation / fan-out. A catalog API may turn one user search into several backend queries (different cohorts, facet requests, A/B variants) and choose result page vs landing page. This layer is also a natural control plane for load shedding.

4. Re-ranking and policy. A separate service enriches candidates with user features and applies ML models plus rules (diversity, recently purchased down-rank, sponsored blend). Zalando's arXiv platform paper describes candidate generation (two-tower → top-500), a multi-task ranking transformer, and a policy layer for page composition. Their DoS post names an Algorithm Gateway for re-ranking / personalization / promotions.

5. Hydration. Return product ids (and scores) from search; fetch card fields from a product read API so the index stays lean.

Latency discipline. Put a hard p99 budget on the whole graph. Skip non-critical enrichments when time runs out. The MICES talk describes roughly splitting network vs processing and falling back when a step no longer fits — phrase as an engineering lesson, not a universal formula.

  1. Elasticsearch — Boolean queryelastic.co
  2. Elasticsearch — kNN searchelastic.co
  3. Zalando Engineering — The Day Our Own Queries DoS'ed Usengineering.zalando.com
  4. arXiv 2409.02856arxiv.org
  5. MICES / Mix-Camp talk listing — Zalando Search architecture (public talk; use as architecture narrative, not private spec)youtube.com

Canonical answer

3.6 How do retrieval and ranking differ, and why separate them?

Lexical or vector retrieval and the subsequent ranker have different latency and freshness constraints, so they are staged separately.

The 30 second answer

Retrieval decides which products are allowed into the candidate set using inverted indexes, filters, and maybe vectors. Ranking orders that smaller set with heavier features and models. Separating them keeps the expensive stage bounded and lets lexical and semantic recall improve without rewriting business rankers. I retrieve thousands of candidates cheaply, then re-rank a few hundred. Debugging also splits cleanly: missed item versus wrong order.

Relevant Search draws a clean line: matching decides membership in the result set; ranking orders members. Debug them separately. That split scales to modern stacks.

Retrieval (candidate generation). Goal: high recall under filters and a tight latency budget. Tools: inverted index Boolean/BM25 retrieval, field-centric or cross-field strategies for multi-attribute queries, and ANN over embeddings for vocabulary mismatch ("comfortable office sneakers" when titles say "loafer"). DDIA's vector chapter summarizes why exact flat search fails at scale and why IVF/HNSW-style ANN trades recall for speed.

Ranking. Goal: precision at the top of the page and business constraints. Features include text score, popularity, margin, stock, user affinity, position bias correction, and promotion eligibility. Compute this only on the candidate set. Zalando's public ranking paper uses a two-tower retriever (top 500) then a transformer ranker with multi-task heads and a policy layer — a concrete published example of the two-stage pattern.

Why separate (Opinion).

  • Cost: deep models on the full catalog every request is impossible at marketplace QPS.
  • Ownership: retrieval engineers tune analyzers/ANN; ranking engineers tune LTR/policy.
  • Safety: filters and legal constraints belong in retrieval; "never show OOS as buyable" is a filter/policy, not a soft boost alone.
  • Experimentation: you can A/B rankers without rebuilding inverted indexes.

Lexical vs neural. Zalando's 2018 cross-lingual DNN post and 2022 meetup talk frame neural search as enrichment for no-hit / vague queries alongside a classic pipeline — not a full replacement. Prefer "hybrid" language in interviews unless the prompt forces pure vector search.

  1. Relevant Search — matching vs ranking (Ch3)manning.com
  2. DDIA — vector indexes / ANN (study map theme 2)dataintensive.net
  3. arXiv 2409.02856arxiv.org
  4. Zalando Engineering — Cross-Lingual End-to-End Product Search with Deep Learningengineering.zalando.com
  5. Elasticsearch — BM25 similarityelastic.co

Canonical answer

3.7 Where do you cache in a product search stack, and what must stay uncached?

Caching is essential for browse and facet traffic, but price and stock cannot be served stale without a stated policy.

The 30 second answer

I cache aggressively on the edges that repeat: popular query strings, common filter combinations, and rendered facet payloads. Coordinator-side result caches help aggregations. I keep user-specific personalized scores lightly cached or computed late so one shopper does not poison another. Price and stock badges need short TTLs or live re-checks. Cache is a performance layer, not the source of truth, so every entry needs TTL and a bust path on ingest.

Cacheable.

  • Normalized query → structured plan (after spell-fix / NER) for popular head queries.
  • Filter combination → candidate counts / facet payloads (Zalando's DoS post notes query builder and Catalog API caching of popular queries and filter combinations, plus coordinator-node caches for results and aggregations).
  • Product card hydration by id (short TTL).
  • Customer embedding / feature-store lookups reused across layers within a request (described in Zalando's ranking paper as near-real-time features with caching).

Risky or short-TTL.

  • Exact "buyable" stock counts.
  • Personalized re-rank scores (privacy + correctness).
  • Anything used for checkout authorization.

Patterns. Cache-aside with TTL is the default. Prefer caching filter clauses and deterministic query shapes because Elasticsearch can cache filters effectively when queries are structured that way (Elasticsearch in Action discusses filter/query caches in the 1.x era; modern docs rename internals — cite current Elastic caching docs for production). For deep pagination, prefer search_after / point-in-time over huge from offsets.

Opinion: during incidents, increasing TTL alone rarely saves you if the new traffic is unique expensive aggregations — Zalando's Dec 2025 post lists longer cache TTL among first actions that did not help when a buggy internal client issued high-cardinality SKU aggregations.

  1. Elasticsearch — Shard request cacheelastic.co
  2. AWS ElastiCache — Caching strategiesdocs.aws.amazon.com
  3. Zalando Engineering — The Day Our Own Queries DoS'ed Usengineering.zalando.com
  4. arXiv 2409.02856 — feature cache notesarxiv.org

Canonical answer

3.8 How do you keep search available when queries or updates overload the cluster?

A self-inflicted query or update overload is a real search-outage mode; admission control has to protect the cluster.

The 30 second answer

I treat search as a layered system with a control plane above Elasticsearch. First I shed load at the edge: fewer parallel fan-outs, more caching, and skip heavy ML or promotion calls. I rate-limit by client and block expensive aggregations. For write storms I throttle ingest and protect refresh. Isolation by market cluster limits blast radius. Runbooks must separate read overload from write overload because the fixes differ.

Failure modes.

  1. Read amplification. One user request becomes many ES queries (facets, A/B variants, promotions). A buggy internal client can look "low QPS" yet destroy CPU with high-cardinality aggregations — Zalando's Dec 2025 post: ~20–100 RPS of bad traffic versus thousands of healthy RPS, rooted via distributed traces.
  2. Write / merge pressure. Bulk ingest + low refresh_interval + heavy merges raise heap and IO; search thread pools starve.
  3. Tail latency under fan-out. DDIA emphasizes percentiles over means: scatter/gather makes p99 dominate user experience.

Mitigations to put on the whiteboard

  • Admission control: rate limits per client type; reject or simplify expensive aggs (search.max_buckets, application-side query limits).
  • Load shedding ladder: disable non-critical fan-outs → sample fewer requests into heavy rankers → fall back to lexical score only.
  • Pool isolation: dedicated coordinating nodes; separate clusters for market groups.
  • Observability hooks: X-Opaque-Id (or equivalent) for per-client slow logs — called out in Zalando's follow-ups.
  • Ingest throttle: pause or slow bulk indexing during rescue; reduce replicas temporarily only with a clear plan to restore redundancy.

Opinion: "just add nodes" is last, not first, when the query shape is pathological. Fix the query taxonomy and client budgets first.

  1. Zalando Engineering — The Day Our Own Queries DoS'ed Usengineering.zalando.com
  2. Elasticsearch — Thread poolselastic.co
  3. Elasticsearch — Search settings (`max_buckets`, etc.)elastic.co
  4. DDIA — percentile latency / tail latency (study map theme 6)dataintensive.net

Canonical answer

3.9 How would you scale product search across regions and large catalogs?

A multi-market catalog has to be served close to shoppers without letting every region depend on one cluster.

The 30 second answer

I scale reads by replicas and by splitting markets onto separate indices or clusters closer to shoppers. I keep primary shard counts intentional so I can add nodes without heroic resharding. Cross-region active-active search usually means independent indexes fed by replicated events, accepting divergence. Global one-index designs hurt latency and blast radius. Capacity plans should include Cyber Week style peaks for both queries and catalog updates.

Horizontal read scale. More replica shards serve more searches; Adaptive Replica Selection (Elasticsearch) prefers healthier copies. Dedicated coordinators absorb scatter/gather merge work.

Partition by geography / catalog. Separate indices or clusters per market (or market group) keep analyzers, assortments, and failure domains local. Zalando's public posts describe multi-cluster deployments, market grouping, and moving a market between clusters with allocation settings, replica reduction, and paused ingest.

Primary shard strategy. Historically Elasticsearch fixed primary shard count at index creation; modern versions add more flexibility, but the interview point remains: over-sharding raises costs and cluster state, under-sharding prevents scaling out. Elasticsearch in Action discusses over-sharding and aliases for reindex; verify against current Elastic docs for your version.

Multi-region (Opinion). For active-active:

  • Replicate events, rebuild indexes regionally (avoid cross-region scatter/gather on every keystroke).
  • Keep sticky user affinity when personalization caches are regional.
  • Accept brief cross-region divergence; define which region is authoritative for a market.

Blue/green for risky changes. Mapping changes, major version upgrades, and embedding model swaps are safer as new clusters/indexes with shadowed traffic (Zalando ES 8 migration; ranking paper embedding refeed).

  1. Elasticsearch — Scale clusterelastic.co
  2. Zalando Engineering — ES 7 to 8 migrationengineering.zalando.com
  3. Zalando Engineering — Failing to Auto Scale Elasticsearch in Kubernetes (Lounge ES; zone-awareness drain lesson — not core search, still a useful ops story)engineering.zalando.com
  4. DDIA — rebalancing / shard counts (study map theme 5)dataintensive.net

Canonical answer

3.10 What would you observe and alert on for a search platform?

You cannot operate search on request-rate alone; relevance, freshness, queueing and shard health need their own signals.

The 30 second answer

I want golden signals for the search API and for Elasticsearch itself: QPS, p95/p99 latency, error rate, and saturation of CPU, heap, and thread pools. I break those down per client and per query class, especially aggregations. Product metrics like zero-result rate and click success catch relevance regressions. Index lag and bulk reject rates catch write-path pain. Traces must show fan-out across query builder, base search, and re-rankers.

RED / golden signals for the edge API. Rate, errors, duration (p50/p95/p99), plus saturation. DDIA's latency discussion is the reason to alert on percentiles, not averages.

Elasticsearch-specific. Query and fetch latency; search / search_coordination thread-pool rejections; JVM heap and GC; young vs old GC; refresh and merge times; bulk indexing latency and rejects; circuit breakers. High-cardinality aggregations deserve their own dashboard.

Per-client attribution. Zalando's incident follow-ups: per-client slow logs via X-Opaque-Id, dashboards for slow and aggregating queries, rate limits by client type. Internal tools are often the real DDoS.

Relevance & product health. Zero-result rate, exact-match rate, add-to-cart after search, revenue proxies, and A/B guardrail metrics. Attribute funnel events so a KPI drop can be traced to a view/query id (MICES monitoring discussion).

Data path health. Consumer lag on the event bus, Flink checkpoint duration/state size, dead-letter depth, and "time from product write to searchable document."

Opinion: page the on-call on user-facing SLO burn and on thread-pool rejections; ticket relevance metric drifts for daytime analysis unless they breach a hard business guardrail.

  1. Elasticsearch — Monitor a clusterelastic.co
  2. Elasticsearch — X-Opaque-Id (opaque id / headers conventions)elastic.co
  3. Zalando Engineering — The Day Our Own Queries DoS'ed Usengineering.zalando.com
  4. Google SRE — Monitoring distributed systemssre.google

Canonical answer

3.11 How do you explain eventual consistency for price and stock in search?

Shoppers see price and stock from the index, which lags the source of truth, and you have to explain what they can trust.

The 30 second answer

Search is a derived view, so it lags the write side by design. I set an explicit lag SLO for price and stock, push those fields on a faster path than rare attribute edits, and always re-validate at checkout. Users may see a brief mismatch; the buy path must not. After a merchant edit I may read the primary service for that id. I never pretend the index is strongly consistent.

Why lag exists. Async replication into a search index means a successful product update can be invisible to shoppers for some window. DDIA's replication chapters cover replica lag and read-your-writes tactics (for example, reading from the leader for recently written data). Elasticsearch itself is near-real-time: documents become searchable after a refresh, not after the index API returns (Elasticsearch in Action Ch refresh discussion; Elastic docs: refresh interval).

Product consequences.

  • Stale price → trust issues.
  • Stale in-stock → failed checkout.
  • Stale title → milder annoyance.

Design moves (Opinion, grounded in common patterns).

  1. Separate fast lanes for price/stock events from slow attribute reindexes.
  2. Publish versions / timestamps on documents; ignore older events in the join processor (Zalando Flink enrichment post drops older-timestamp events).
  3. Checkout and cart APIs re-fetch authoritative price/stock from the write model.
  4. Merchant "preview" UIs may call _refresh or a primary read path — never make global refresh_interval tiny just for that.
  5. Communicate soft availability ("few left") when exact counts are cached.

What to say in the interview. "We choose eventual consistency for discovery throughput; we bound it with lag SLOs and we never authorize purchase from the search replica."

  1. Elasticsearch — Near real-time search / refreshelastic.co
  2. DDIA — replication lag / read-your-writes (study map theme 7)dataintensive.net
  3. Zalando Engineering — Flink offer enrichmentengineering.zalando.com
  4. Zalando Engineering — Product read API / event-driven servingengineering.zalando.com

Canonical answer

3.12 A business transaction spans multiple databases. How do you keep it consistent?

Catalog writes, stock and the search index do not share one database, so consistency is a design problem, not a local transaction.

The 30 second answer

I avoid distributed two-phase commit across services and use a saga instead. It is a chain of local transactions, coordinated by events or by an orchestrator, and if a step fails, compensating transactions undo the earlier steps. The cost is that there is no isolation, so others can see in-between states. I handle that with explicit statuses like pending and with idempotent steps.

Avoid distributed 2PC across services. Use a saga: a chain of local transactions where each step publishes an event or is called by an orchestrator, and a failed step triggers compensating transactions that undo earlier steps. The cost is that there is no isolation, so other readers can see in-between states; I handle that with status fields such as PENDING and with idempotent steps. Follow-up: Orchestration or choreography? (Orchestration when the flow has many steps and needs one place to see the state.) Alternate interview scenarios

"Design a saga for Order, Payment and Inventory." A concrete orchestrated version works like this. Order service creates the order as PENDING and starts the saga. Then Inventory reserves the stock, and Payment charges the customer. If both succeed, the order becomes CONFIRMED. If the payment fails, the orchestrator runs the compensations in reverse order: it releases the reservation and marks the order CANCELLED. Three details matter. First, every step and every compensation must be idempotent, because messages get redelivered. Second, a reservation needs a timeout, so stock does not stay locked when the saga stalls. Third, each service should publish its step result through a transactional outbox, so a database change and its event cannot get out of sync. Order the steps so that the hardest step to undo, here the payment charge, runs last, or use authorise-then-capture so that undoing it is cheap.

  1. microservices.io: Sagamicroservices.io
  2. microservices.io: Transactional outboxmicroservices.io

Canonical answer

3.13 How do you publish events reliably when you update the DB and send to Kafka?

An outbox keeps a catalog write and the event that updates search in step, instead of a best-effort publish after commit.

The 30 second answer

Writing to the database and Kafka as two separate steps can fail halfway. So I use the transactional outbox: the business row and an outbox row are written in one database transaction, and a relay using polling or change data capture publishes the outbox rows to Kafka. Delivery is at least once, so consumers deduplicate by event ID, and the idempotent producer stops duplicate writes on retry.

Don't write to the DB and Kafka in two separate steps, because one can fail after the other succeeds. Use the transactional outbox: write the business row and an outbox row in one DB transaction, and a relay (polling or CDC) publishes outbox rows to Kafka. Delivery is at least once, so consumers dedupe by event ID; the idempotent producer (enable.idempotence=true) stops producer retries from writing duplicates. Follow-up: Does Kafka exactly-once cover the DB write? (No, only Kafka-to-Kafka; side effects still need idempotency.)

  1. microservices.io: Transactional outboxmicroservices.io
  2. Kafka: Message delivery semanticskafka.apache.org
  3. Kafka producer configskafka.apache.org

Canonical answer

3.14 How do you evolve Kafka message schemas with many consumer versions?

Search, browse and enrichment consumers often run at different versions, so catalog event schemas have to evolve safely.

The 30 second answer

I use a schema registry with a compatibility mode. Backward, the default, means new consumers can read old data, so consumers upgrade first. Forward means old consumers can read new data, so producers upgrade first. Full means both, and the transitive variants check against all past versions. Safe changes are adding optional fields with defaults. I never rename or reuse a field.

Use Schema Registry with a compatibility mode. The default is BACKWARD: new consumers can read old data, so upgrade consumers first. FORWARD means old consumers can read new data, so upgrade producers first. FULL means both. The _TRANSITIVE variants check against all past versions, not only the last. Safe changes are adding optional fields with defaults; never rename or reuse a field. Follow-up: Which mode when you can't control consumer upgrade timing? (FULL or FULL_TRANSITIVE.) Alternate interview scenarios

"A deployment breaks communication between services. How do you handle versioning?" This is the synchronous API version of the same contract problem, without a registry that enforces compatibility. I prefer additive changes: new optional fields, and never removing or renaming a field that consumers still read. Consumers must be tolerant readers that ignore unknown fields, which Jackson can be configured to do. For a breaking change, I run two API versions side by side, migrate consumers, and retire the old version only when its traffic is zero. Since Spring Framework 7 (Spring Boot 4), Spring MVC has built-in API versioning: it resolves the version from a header, query parameter, media type or path, and can send deprecation hints to clients. To catch breaks before deploy, I add consumer-driven contract tests such as Pact or Spring Cloud Contract to CI, and I use a canary rollout so a contract break reaches only a few requests.

  1. Confluent: Schema evolution and compatibilitydocs.confluent.io
  2. Spring Framework reference: API versioning (Spring MVC)docs.spring.io
  3. Pact documentation (consumer-driven contract testing)docs.pact.io
  4. Spring Framework 7.0 release notes: API Versioninggithub.com
  5. Spring Boot 4.0 release notes: Spring Framework 7.0 and API Versioning auto-configurationgithub.com

Canonical answer

3.15 Kafka consumer lag keeps rising, but CPU and DB look normal. Why?

A lagging catalog or stock consumer can look idle on CPU while the index quietly goes stale.

The 30 second answer

When CPU and the database look fine, the consumer is usually waiting rather than working. Common causes are a hot partition from a skewed key, rebalances because processing takes longer than the max poll interval, slow synchronous calls to another service, or tiny fetch batches. I check lag per partition with the consumer groups tool and the consumer lag metrics before changing anything.

If nothing is busy, the consumer is waiting, not working. Check: fewer partitions than consumers you need (one partition goes to only one consumer in a group); a hot partition from a skewed key; rebalances caused by processing slower than max.poll.interval.ms; slow synchronous calls to another service; or small fetch batches. I check lag per partition with kafka-consumer-groups.sh --describe and the consumer's lag metrics. Follow-up: How do you scale past the partition count? (Add partitions, which changes key placement, or process records in parallel inside the consumer with per-key ordering.) Editor's note: the list of causes comes from experience; the tools and config names come from the Kafka docs. Alternate interview scenarios

"Kafka consumer lag keeps increasing. What would you investigate?" This version does not say that the CPU and the database are fine, so I first find which side changed. Either producers are writing faster, or consumers are processing slower. I compare the producer's incoming rate with the consumer's records-consumed rate. If lag grows on every partition, it is a throughput problem. I check consumer CPU, database latency and downstream calls, then the waiting causes above. If lag grows on one partition, it is a skewed key or a stuck record, such as a message that fails and is retried forever. I check records-lag-max and the rebalance logs. A stuck record needs a dead-letter topic, not more consumers.

"Design a system for millions of events per hour with reliable processing and controlled backpressure." A million events per hour is only about 280 per second, so throughput is rarely the hard part. The hard part is reliability and handling bursts. Kafka consumers pull, so backpressure comes for free: a slow consumer simply falls behind, and lag is the signal. I bound the work per poll with max.poll.records and keep processing within max.poll.interval.ms. When a downstream is saturated, pause() the partitions and resume() them later instead of buffering without limit in memory. Reliability comes from committing offsets after processing, idempotent handlers, a retry topic and a dead-letter topic for records that keep failing. I scale with partitions keyed by entity and alert on lag and its growth rate.

  1. Kafka consumer configs (max.poll.interval.ms, fetch settings)kafka.apache.org
  2. Kafka: Basic operations (consumer groups)kafka.apache.org
  3. Kafka: Monitoring (consumer fetch metrics, records-lag-max)kafka.apache.org
  4. KafkaConsumer Javadoc (flow control with pause and resume)kafka.apache.org

Canonical answer

3.16 Kafka consumer crashes after processing but before committing the offset?

If a consumer updates the index and then crashes before the offset commit, the same catalog event will be processed again.

The 30 second answer

On restart or rebalance the partition resumes from the last committed offset, so the message is processed again. That is at-least-once delivery. Effects must be idempotent: a dedupe table keyed by message or business id, upserts, or idempotent downstream APIs. Kafka transactions can give exactly-once inside Kafka, but external side effects still need idempotency. Committing before processing flips you to at-most-once and can lose work.

On restart/rebalance the partition resumes from the last committed offset → the message is processed again (at-least-once). Effects must be idempotent: dedupe table keyed by message/business id, upserts, idempotent downstream APIs. For Kafka→Kafka pipelines, transactions (read_committed + offsets committed in the producer transaction) give exactly-once within Kafka; external side effects still need idempotency. (Committing before processing flips it to at-most-once → loss.)

Alternate interview scenarios

"How do you handle Kafka duplicates, idempotency and ordering, and what does exactly-once really mean?"

This version asks for the full design, not just the crash case. Duplicates come from two places. On the producer side, a retry after a lost acknowledgement can write the same record twice, and enable.idempotence stops this within a partition. On the consumer side, a redelivery after a crash or rebalance processes the same record again, and the consumer must handle that with a dedupe table or idempotent writes. Kafka guarantees order only within one partition. So I use the entity id as the key, so that all events for one order land on the same partition. The Kafka docs warn that with several requests in flight and idempotence disabled, retries can reorder messages. Exactly-once in Kafka means a read-process-write pipeline inside Kafka with transactions and read_committed consumers. Once the effect is a database row or an email, I am back to at-least-once delivery plus idempotent effects.

  1. Kafka docs — Message delivery semanticskafka.apache.org
  2. Kafka producer configs (enable.idempotence, max.in.flight.requests.per.connection)kafka.apache.org

Canonical answer

3.17 What is the difference between at-most-once, at-least-once and exactly-once

The delivery guarantee you choose for catalog and stock events decides whether search can drop an update or must tolerate duplicates.

The 30 second answer

At-most-once means I commit the offset before processing, so a crash can lose the record. At-least-once means I process first and then commit, so a crash can deliver it again; that is Kafka's default, and the consumer must be idempotent. Exactly-once applies to a read-process-write pipeline inside Kafka, using a transactional producer and a read_committed consumer. It does not, by itself, make a database update or a payment API happen only once.

The Kafka design docs define the three guarantees in terms of what happens when a producer or a consumer fails.

At-most-once. Messages may be lost and are never redelivered. On the consumer, that means saving the position and then processing. If the process dies after the commit, the record is skipped. On the producer, it means not retrying, or using acks=0. I only choose this when losing a record is acceptable, for example a metric sample.

At-least-once. Messages are never lost but may be redelivered. The consumer processes and then commits. The design docs state that Kafka "guarantees at-least-once delivery by default." A crash after the side effect and before the commit, or a producer retry after a lost acknowledgement, can produce a duplicate. The practical answer is an idempotent consumer, and, since Kafka 3.0, the idempotent producer which is on by default so producer retries do not duplicate a record inside a partition.

Exactly-once. "Each message is processed once and only once," but the fine print matters. Since 0.11.0.0 the transactional producer can write to several partitions atomically, and the consumer's committed offset can be included in the same transaction. Consumers that should not see aborted transactions must set isolation.level=read_committed. The Kafka 4.4 default is still read_uncommitted. The transactional producer also needs a transactional.id; without it the producer is only idempotent, not transactional. Auto-commit must be off (enable.auto.commit=false).

The docs are explicit about the boundary: this gives exactly-once when you are reading, processing and writing on Kafka topics, as Kafka Streams does. A write to an external system still needs cooperation with that system, typically by storing the offset next to the output, or by making the output idempotent. I say that out loud in an interview. Otherwise it sounds as if turning transactions on will make a payment API safe, and it will not.

  1. Kafka 4.4 design: Message delivery semanticskafka.apache.org
  2. Kafka 4.4 design: Using transactionskafka.apache.org
  3. Kafka 4.4 producer configs (`transactional.id`, `enable.idempotence`)kafka.apache.org
  4. Kafka 4.4 consumer configs (`isolation.level`, `enable.auto.commit`)kafka.apache.org

Canonical answer

3.18 How do you make processing idempotent when the same event arrives twice

At-least-once delivery of catalog events is the usual case, so index updates must be safe to apply twice.

The 30 second answer

Duplicates are normal under at-least-once delivery. I give every event a stable id and record it in a processed-events table with a unique constraint, in the same database transaction as the business write. A replay then hits the constraint and is skipped. If the write is naturally idempotent, an upsert or a version check is enough. I do not rely on Kafka exactly-once for an external payment or email, because that effect lives outside Kafka.

Start by saying where the second copy came from, because the fix is different.

Producer retries. If the producer times out after the broker has already appended the record, a retry can write it again. Since Kafka 0.11 the idempotent producer assigns a producer id and a sequence number so those retries do not create a second log entry on that partition. In the Kafka 4.4 producer configs, enable.idempotence defaults to true when no conflicting settings are present, acks defaults to all, and max.in.flight.requests.per.connection defaults to 5, which is the maximum the idempotent producer allows while still preserving order. That default was introduced in Kafka 3.0 by KIP-679. In 3.0.0 and 3.1.0 a bug, KAFKA-13598, left the idempotence default unapplied unless you set it yourself; it was fixed in 3.0.1, 3.1.1 and 3.2.0. I still set enable.idempotence=true explicitly in application config so a conflicting acks or retries value cannot silently turn it off.

Consumer redelivery. This is the case the question is usually about. After a crash, a rebalance, or a commit that never reached the broker, the group restarts from the last committed offset and the handler sees the same record again. The idempotent producer does not help here. I put a unique event id on the payload (or use a unique business key such as orderId + eventType + version) and persist it with the side effect:

INSERT INTO processed_events (event_id, processed_at)
VALUES (:eventId, now())
ON CONFLICT (event_id) DO NOTHING;

If the insert is skipped, I skip the business write as well. Both statements must share one database transaction so I cannot apply the payment and then fail to record the id, or the reverse. An inbox table is the same idea with a pending state, useful when the side effect is itself remote.

If the write is naturally idempotent, I can skip the extra table: INSERT ... ON CONFLICT on the business key, or UPDATE ... WHERE version < :incoming so an older replay cannot overwrite a newer state. I do not use "check then insert" without a unique constraint; two threads will both pass the check.

Editor's note: Kafka transactions can fold a Kafka-to-Kafka write and the offset commit into one transaction. They do not make a card charge or an HTTP call exactly-once. For those I still want an idempotency key on the downstream API.

  1. Kafka 4.4 design: Message delivery semanticskafka.apache.org
  2. Kafka 4.4 producer configs (`enable.idempotence`, `acks`, `max.in.flight.requests.per.connection`)kafka.apache.org
  3. KIP-679: Producer will enable the strongest delivery guarantee by defaultcwiki.apache.org
  4. KAFKA-13598: Producer idempotence default not applied in 3.0.0 and 3.1.0issues.apache.org
  5. PostgreSQL 17: INSERT ... ON CONFLICTpostgresql.org

Canonical answer

3.19 How do you publish events reliably when you update the database and send to Kafka

This is the fuller outbox treatment: the database write and the event that feeds search have to be one logical commit.

The 30 second answer

I never write to the database and to Kafka in two separate steps, because one can succeed after the other fails. I write the business row and an outbox row in the same database transaction, and a relay, either polling or change data capture, publishes the outbox to Kafka. Delivery is still at-least-once, so consumers deduplicate by event id. The idempotent producer stops a retried send from creating a second log entry.

This is the dual-write problem. A database commit and a Kafka send() are not one atomic operation. If you write the order row and then the process crashes before send() completes, the event never leaves. If you send first and then the database rolls back, consumers act on an order that does not exist.

The transactional outbox solves it by making the event part of the same database transaction as the business write. The outbox table holds an id, the aggregate id, a type, a payload, and a published flag or timestamp. A relay then publishes unpublished rows to Kafka. Two common relays:

  • A worker that SELECT ... FOR UPDATE SKIP LOCKED a batch, produces the records, and marks them published.
  • Change data capture on the outbox table, for example Debezium's outbox event router, so the publication is driven from the write-ahead log rather than from polling.

Delivery from the relay is still at-least-once: a crash after a successful produce and before marking the row published will send the event again. Consumers deduplicate by event id. The idempotent producer (enable.idempotence=true) stops the relay's own retries from appending a second copy of the same produce request.

Kafka transactions do not replace the outbox when the source of truth is the database. They atomicise writes to Kafka, including the consumer offset, not a write to Postgres plus a write to Kafka.

The same pattern, aimed at a microservices audience, is also answered in How do you publish events reliably when you update the DB and send to Kafka?.

  1. microservices.io: Transactional outboxmicroservices.io
  2. Debezium: Outbox Event Routerdebezium.io
  3. Kafka 4.4 design: Message delivery semanticskafka.apache.org
  4. PostgreSQL 17: SELECT ... FOR UPDATE and SKIP LOCKEDpostgresql.org

Canonical answer

3.20 How do you guarantee ordering when events for the same entity arrive out of sequence

Price and stock events for one SKU have to be applied in order, or the index can briefly show a stale or future state.

The 30 second answer

Kafka only preserves order within a single partition. I use the entity id, such as the order id, as the message key so every event for that entity lands on the same partition and is consumed in offset order. I keep one consumer in the group on that partition, and I leave producer idempotence on so retries cannot reorder in-flight batches. I do not add partitions later without a plan, because that changes the key-to-partition map.

The Kafka introduction is unambiguous: a topic is a partitioned log, and "there is no global ordering across partitions." Order is the offset order inside one partition. If OrderCreated and OrderPaid for the same order are hashed to different partitions, a consumer can see payment first.

The fix starts at the producer. I set the record key to the entity id (the order id, the SKU, the account id) so the default partitioner sends every event for that entity to the same partition. One consumer in a group is assigned that partition, so it sees the records in order.

Two producer settings can still break that order. If enable.idempotence is false and max.in.flight.requests.per.connection is greater than one, a retried batch can land after a later batch that already succeeded. The Kafka 4.4 producer docs spell this out. With idempotence enabled, which is the default when settings do not conflict, ordering is preserved and max.in.flight.requests.per.connection may be at most 5. I leave those defaults alone unless I have a measured reason not to.

On the consumer, I do not process records from the same partition on many threads unless I pin all records with the same key to the same worker. Otherwise two workers can apply OrderPaid before OrderCreated. I also treat non-blocking retry topics with care: a failed record that is forwarded to a retry topic no longer blocks the partition, but a later event for the same key can be processed first. If order matters, I retry in place, or I retry in a way that still keys by the entity.

Adding partitions later changes the hash of every key, so new events for an existing entity can land on a different partition and appear "out of sequence" relative to the old ones. If I must expand, I plan a migration, or I accept that only events written after the change are comparable on the new assignment.

Consumers can also apply a version or event-time check and drop a stale event, which is how an upsert of "latest state per key" stays correct under retries. That is a safety net, not a substitute for keying correctly.

  1. Kafka 4.4 introduction (topics, partitions and ordering)kafka.apache.org
  2. Kafka 4.4 producer configs (`enable.idempotence`, `max.in.flight.requests.per.connection`)kafka.apache.org
  3. Kafka 4.4 design: The producer (semantic partitioning)kafka.apache.org

Canonical answer

3.21 How do you handle growing consumer lag when producers outpace consumers

When catalog or clickstream producers outpace the indexer, lag is a freshness problem you have to drain, not only watch.

The 30 second answer

Lag means the consumer is behind the log end offset. If every partition is behind, producers are faster than the group, so I speed up processing, add partitions and then consumers, or pause while a downstream cools down. Adding consumers beyond the partition count does nothing, because one partition is assigned to only one consumer in a group. If only one partition is behind, I look for a hot key or a stuck record, not more consumers.

Lag is how far the consumer's committed position sits behind the log end offset. Kafka consumers pull, so lag is also the backpressure signal: a slow group simply falls behind instead of being flooded.

I first look at where the lag is growing, with kafka-consumer-groups.sh --describe and the client's records-lag-max metric.

Every partition is behind, and the consumers are busy. The group cannot keep up with the produce rate. I shorten the work per record, cut synchronous downstream calls, or raise throughput inside the current partition count, for example by processing independent keys in parallel. I keep max.poll.records (default 500 in Kafka 4.4) and processing time inside max.poll.interval.ms (default five minutes) so the member is not evicted while it works. If I still need more parallelism, I add partitions and then add consumers. A group assigns each partition to only one member, so starting ten consumers on a three-partition topic leaves seven idle. Adding partitions changes key placement, so I treat that as a planned change, not a live toggle.

Every partition is behind, but CPU and the database look idle. The consumer is waiting, not working. Typical causes are a blocked poll loop, a session that is rebalancing, tiny fetch sizes, or a downstream call that is slow but not visibly burning CPU. That investigation is also answered in Kafka consumer lag keeps rising, but CPU and DB look normal. Why?.

Only one partition is behind. This is a skewed key or a stuck record, not a missing consumer. More members will not help. I fix the key or park the poison record.

For a temporary downstream outage I pause the assigned partitions with KafkaConsumer.pause and resume them later, rather than buffering an unbounded in-memory queue. The KafkaConsumer Javadoc documents this flow-control API. I alert on lag and on the rate at which lag is growing, not only on a static threshold.

  1. Kafka 4.4 consumer configs (`max.poll.interval.ms`, `max.poll.records`)kafka.apache.org
  2. Kafka 4.4: Basic Kafka operations (consumer groups)kafka.apache.org
  3. Kafka 4.4: Monitoring (`records-lag-max`)kafka.apache.org
  4. KafkaConsumer Javadoc (pause and resume)kafka.apache.org

Canonical answer

3.22 What happens to in-flight work when a new consumer joins and partitions are reassigned

A rebalance mid-index-update can replay or skip work unless the consumer's commit and processing boundaries are explicit.

The 30 second answer

On the classic protocol with the default Range assignor, rebalancing is eager: every member revokes all partitions, in-flight work must be committed in onPartitionsRevoked, and processing stops until the new assignment. Cooperative sticky revokes only the partitions that must move. I set group.instance.id so a short restart does not rebalance the group. Since Kafka 4.0 I can opt into the KIP-848 consumer protocol with group.protocol=consumer, which is incremental and broker-coordinated.

A new member joining, a member leaving, or a member missing a poll or a heartbeat all trigger a rebalance. What happens to work already in flight depends on the protocol and the assignor.

Classic protocol, eager assignor. This is still the starting point. In the Kafka 4.4 client, group.protocol defaults to classic, and partition.assignment.strategy defaults to [RangeAssignor, CooperativeStickyAssignor], which means Range is used until you remove it from the list. Range is eager. The ConsumerRebalanceListener Javadoc says that in eager rebalancing, onPartitionsRevoked is called with the full set of assigned partitions. Every member stops fetching, revokes everything, and waits for a new assignment. In-flight records that have been processed but not committed will be delivered again to whoever receives that partition, unless you commit in onPartitionsRevoked. The Javadoc recommends committing offsets in that callback "to prevent duplicate data." Processing on partitions that will be assigned back to the same member still pauses for the duration of the rebalance. That is the stop-the-world behaviour KIP-429 was written to avoid.

Classic protocol, cooperative sticky. CooperativeStickyAssignor (KIP-429) revokes only the partitions that must actually move. onPartitionsRevoked is invoked with that possibly-empty set, and only then, if it is non-empty. Partitions that stay with the member can keep being processed. The Kafka 4.4 consumer configs note that the default list is there so you can upgrade to cooperative sticky with a single rolling bounce that removes RangeAssignor. I do not mix eager and cooperative members in the same group during that bounce beyond what the upgrade procedure describes.

Static membership. KIP-345 lets me set group.instance.id to a stable, unique id per instance. The member is then static: a brief restart can rejoin with the same id inside the session timeout and keep its partitions, instead of causing a group-wide rebalance. The Kafka 4.4 default for group.instance.id is null (dynamic membership). This helps planned restarts and rolling deploys; it does not help if the process stays down longer than the session timeout.

KIP-848, the next-generation protocol. This is generally available from Kafka 4.0. The 4.0 release announcement and the current "Consumer Rebalance Protocol" page say the broker supports it automatically, but the client must opt in with group.protocol=consumer. The client default remains classic in Kafka 4.4. The new protocol is incremental and broker-coordinated; it no longer uses a global synchronization barrier. Heartbeat and session timeout move to the broker (group.consumer.heartbeat.interval.ms, group.consumer.session.timeout.ms). When it is on, the following classic client settings are not used: heartbeat.interval.ms, session.timeout.ms, partition.assignment.strategy, and enforceRebalance(). onPartitionsRevoked then behaves like cooperative mode: it is called with the partitions to revoke only when that set is non-empty. I would mention this in an interview as the direction of travel, and I would say it is an opt-in on the client, not a silent default.

Lost partitions. If the member is evicted (for example the session timeout expires) without a clean revoke, onPartitionsLost runs instead. Those partitions may already have been assigned elsewhere, so this is the wrong moment to commit offsets. The Javadoc's default is to call onPartitionsRevoked; I override it so I only clean up, I do not commit.

  1. ConsumerRebalanceListener Javadoc (`onPartitionsRevoked`, `onPartitionsAssigned`, `onPartitionsLost`)kafka.apache.org
  2. Kafka 4.4 consumer configs (`group.protocol`, `partition.assignment.strategy`, `group.instance.id`)kafka.apache.org
  3. Kafka 4.4: Consumer rebalance protocol (KIP-848)kafka.apache.org
  4. Apache Kafka 4.0.0 release announcement (KIP-848 generally available)kafka.apache.org
  5. KIP-429: Kafka Consumer Incremental Rebalance Protocolcwiki.apache.org
  6. KIP-345: Introduce static membership protocol to reduce consumer rebalancescwiki.apache.org
  7. KIP-848: The Next Generation of the Consumer Rebalance Protocolcwiki.apache.org

Canonical answer

3.23 How do you stop a repeatedly failing message from blocking a partition

A poison catalog event that blocks a partition stalls updates for every SKU that shares it.

The 30 second answer

A poison record fails every time, so a blocking retry loop stalls the whole partition and lag grows behind it. I treat deserialization and validation errors as non-retryable and publish them to a dead-letter topic, then commit and continue. Spring Kafka's ErrorHandlingDeserializer catches corrupt payloads before the listener, and DefaultErrorHandler with DeadLetterPublishingRecoverer can park the record. I alert on dead-letter volume so the parked events are actually fixed.

Kafka consumers in a group read a partition in offset order. If I retry the same record indefinitely in the listener thread, I never reach the records behind it. Lag on that partition grows, and every later event for keys on that partition is delayed. That is a poison pill.

The first split is retryable versus not. A database outage or a 503 from a dependency is transient. A payload that will never deserialize, or that fails a schema or validation rule, will not improve with time. The second class must leave the partition.

In Spring Kafka, deserialization errors happen before the listener runs, so a try/catch in the handler never sees them. ErrorHandlingDeserializer wraps the real deserializer, records the failure on the headers, and lets the error handler take over. DefaultErrorHandler, which replaced SeekToCurrentErrorHandler, can then give up and call a DeadLetterPublishingRecoverer, which publishes the record to a dead-letter topic. I register deterministic exceptions with addNotRetryableExceptions(...) so they skip retries and go straight to the recoverer. After recovery, the consumer can commit and continue with the next offset.

The trade-off is the same one any cursor-based log has: skipping preserves flow and risks a gap; stopping preserves order and stalls everything behind the bad record. Parking the record on a dead-letter topic, with enough headers (original topic, partition, offset, exception) to replay it, is how I skip without forgetting. I alert on the size or rate of that topic. A dead-letter topic that nobody reads is just a quieter form of data loss.

If the failure is transient, I do not use this path on the first attempt. That is the next question.

  1. Spring for Apache Kafka: Handling exceptions (`DefaultErrorHandler`, `DeadLetterPublishingRecoverer`)docs.spring.io
  2. Kafka 4.4 introduction (partition as an ordered log)kafka.apache.org

Canonical answer

3.24 How do you keep compatibility when a producer changes the event schema while older consumers are still running

Producers will add fields to catalog events while older search consumers are still running, so compatibility has to be designed in.

The 30 second answer

I put the events through a schema registry and pick a compatibility mode. If older consumers are still running, backward compatibility is not enough, because a new producer field would break them; I need forward or full compatibility, and I only add optional fields with defaults. I never rename or reuse a field. If I cannot control when consumers upgrade, I use FULL or FULL_TRANSITIVE and I roll the producer out gradually.

This is a contract problem, not a Kafka-broker problem. The broker stores bytes. Compatibility lives in the schema, and in whether old and new code can both read those bytes.

Confluent Schema Registry (and compatible registries) enforce a compatibility mode when a new schema is registered:

  • BACKWARD (the Confluent default): a new consumer can read data written with the previous schema. The safe rollout is to upgrade consumers first, then the producer. That does not help when older consumers are still running and the producer has already changed.
  • FORWARD: an old consumer can read data written with the new schema. The safe rollout is to upgrade producers first. This is the mode that matches the question as asked.
  • FULL: both of the above. FULL_TRANSITIVE and BACKWARD_TRANSITIVE / FORWARD_TRANSITIVE check against every previously registered version, not only the last one.

The changes that usually stay compatible are adding a field with a default (old consumers ignore it, or they use the default when the producer has not started writing it, depending on the direction) and adding a new optional member. The changes that break consumers are removing a field they still read, renaming a field, changing a type, or reusing a field id or name for a different meaning.

When I cannot control consumer upgrade timing, I choose FULL or FULL_TRANSITIVE, I keep producers on additive changes, and I roll the producer out gradually so a mistake is limited. For a truly breaking change I run two event types or two subjects in parallel, migrate consumers, and only then stop writing the old shape.

The same schema-registry answer, in a slightly more general wording, is also on How do you evolve Kafka message schemas with many consumer versions?.

  1. Confluent: Schema evolution and compatibilitydocs.confluent.io

Canonical answer

3.25 What does a core search API layer own, and why put one between product teams and Elasticsearch?

The 30 second answer

A core search API turns product intent into Elasticsearch queries in one place. It owns the query language, the client library and its upgrades, guardrails on expensive query shapes, caller identification, and the composition of ranking and other search services. Product teams ask for search, not raw DSL. Zalando's upgrade post describes its Origami service this way, as the connector that coordinates search intelligence on top of Elasticsearch.

What Zalando has published. Its Elasticsearch upgrade post names the main application that talks to Elasticsearch: Origami, quoted from its internal description as "the Zalando Core Search API", providing "a powerful information retrieval language and engine" that integrates microservices built by the search department and is "the connector - coordinating all search intelligence to serve correct search results to customers". It builds on Elasticsearch and internal APIs for search and discovery, navigation and analytics. The post says it is written in Scala and large (443k lines across 846 files, including configs, tests and test resources), and that most of the upgrade work was migrating its Elasticsearch client. A later Zalando post on a search outage describes layers such as a query builder, a catalog API and a lightweight Search API in front of the cluster. The public posts do not map those names onto each other, so in an interview talk about responsibilities, not about which box is which.

Why have the layer at all.

  1. One query language. Product teams express intent (text, filters, sort, page, market) and the layer turns it into Elasticsearch DSL. Mappings, analyzers and index names can change without touching every caller.
  2. One client to upgrade. When the Elasticsearch client changes, as with the deprecated High Level REST Client in the upgrade post, one codebase changes. The REST compatibility mode and the three-way client and cluster test matrix described in that post are only practical because there is one caller.
  3. Guardrails. The layer is where you enforce which fields may be aggregated, maximum size and pagination depth, timeouts, and terminate-after limits. The outage post's follow-ups (application-side query limits with dynamic thresholds, rate limits by client type) live naturally here.
  4. Caller identity. It can attach X-Opaque-Id per calling service, so slow logs and the task API show who sent an expensive query.
  5. Composition. Query understanding, retrieval, re-ranking, promotions and fallbacks are orchestrated with a latency budget per step, and with A/B variants of query shapes.
  6. Load control. During incidents the presentation layers act as a control plane: fewer parallel queries per request, more caching for hot queries, cheaper ranking fallbacks, which is what the outage post describes.

The risks to name. A central layer can become a bottleneck for every product change and a single point of failure. Mitigate with clear extension points (configuration-driven query templates, feature flags), independent deployability, horizontal scaling, strict per-dependency timeouts, and ownership boundaries so teams are not blocked on one backlog.

Alternate interview scenarios

"A product team wants raw Elasticsearch access for a new feature. Do you allow it?"

I would rather add the capability to the search API, because direct access bypasses the guardrails and the client upgrade path, and the outage post shows how one unmonitored internal caller can degrade search for everyone. If speed matters, I would agree a narrow, read-only endpoint with fixed query templates, a caller id, a timeout and a quota, instead of open DSL.

"How would you migrate a large search API to a new Elasticsearch client safely?"

Put an internal interface in front of the client so most code depends on it, run the old client in compatibility mode against the new cluster first, and add contract tests that run the important query shapes against both cluster versions in containers. Then migrate call sites in slices behind flags, comparing responses from both paths in shadow mode before switching.

  1. Zalando Engineering: Migrating From Elasticsearch 7.17 to Elasticsearch 8.x: Pitfalls and Learningsengineering.zalando.com
  2. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Searchengineering.zalando.com
  3. Elasticsearch: API conventions (X-Opaque-Id)elastic.co

Open as a standalone page

3.26 How does a cursor-based event bus such as Nakadi deliver events, and what happens with a poison event?

The 30 second answer

Nakadi, Zalando's open-source event bus, exposes Kafka-style partitioned event types over HTTP. Each partition is an ordered log, and consumers track position with cursors. With the subscription API, Nakadi assigns partitions to consumers, streams batches, and expects cursor commits, giving at-least-once delivery. Because position is a cursor, a poison event forces a choice: stop the partition until it is fixed, or skip it and park it somewhere for repair.

Nakadi is Zalando's open-source event broker. Its manual describes it as a RESTful API over a partitioned log similar to Kafka, with event types, schemas and per-event-type authorization. It is a good public example to reason about event delivery in a catalog pipeline.

Ordering and partitions. An event type is split into partitions. The manual says each partition is a fully ordered log and "there is no global ordering across partitions". Producers choose a partition strategy; with the hash strategy and partition_key_fields, all events for the same key (an order number, or a SKU in a catalog) land in the same partition, so they are consumed in order.

Consuming with subscriptions. With the subscription API, Nakadi manages consumption. Clients open a stream; Nakadi assigns partitions automatically, and "the balancing unit is the partition", so a subscription cannot usefully have more clients than partitions. Clients receive batches with cursors and commit them by posting to the subscription's cursors resource. The manual describes the guarantee as at-least-once: if a commit fails, a client can see the same event again, but events that were committed successfully are not resent. Consumers should therefore be idempotent.

Flow control. Two stream parameters matter in a design discussion. max_uncommitted_events (default 10) is the number of uncommitted events Nakadi will stream before pausing; a commit resumes the stream. commit_timeout (at most 60 seconds) is how long Nakadi waits for a commit after sending a batch; if it does not arrive, the stream is terminated and its partitions are reassigned to other consumers. A consumer that processes slowly must commit in time or it will churn.

The poison event. Nakadi's own comparison with SQS explains the trade-off well. In SQS, each message is acknowledged individually, so only the unprocessable message is delayed and eventually moved to a dead-letter queue. With cursor semantics, as in Nakadi, Kinesis or Kafka, the client "has to decide: either stop all further message processing until the problem is fixed or skip the message and move the cursor". In a search ingestion pipeline:

  • Stopping preserves order for that partition but blocks every product behind it, so search goes stale for a slice of the catalog.
  • Skipping keeps the pipeline moving but loses the event unless you write it to a parking store with enough context to replay it after the fix.

A practical rule: validate against the schema at the edge, retry transient failures with backoff, and send events that fail deterministically to a parking store, alerting on its size. Because later events for the same SKU usually carry the full current state, a skipped event is often superseded, but a periodic reconciliation or reindex should still catch gaps.

Compaction and retention. An event type can use the compact cleanup policy to keep only the latest event per key, useful for a "current state of every product" stream that a new consumer can bootstrap from. Retention for normal event types is an operational setting: the open-source project's default is two days, and the manual warns that each installation may differ.

Alternate interview scenarios

"Why put an HTTP API in front of Kafka instead of letting every team use Kafka clients?"

The manual's reasons are organisational: many languages, protection of the cluster from misbehaving clients, schema enforcement, and authorization per event type. The cost is an extra hop and fewer client-side features, so it is a platform decision rather than a performance one.

"Consumer lag grows on one partition only. What do you suspect?"

A hot key that sends most events to that partition, a poison event that is being retried in a loop, or a slow downstream call for a subset of products. Per-partition lag and processing-time metrics, plus the offset of the last committed cursor, tell them apart. The Kafka-side forms of this are Why can one hot partition stall a consumer group, and how do you fix it? and How do you handle growing consumer lag when producers outpace consumers?.

The same cursor-log trade-offs, written against Apache Kafka rather than Nakadi, are answered in more depth on the Kafka interview page: at-least-once versus exactly-once, idempotent processing when the same event arrives twice, per-partition ordering, a crash after processing but before the offset is committed, a poison message that blocks the partition, retry, backoff or a dead-letter topic, and schema compatibility while older consumers are still running. Nakadi's HTTP API and cursor commits are the platform-specific part; those Kafka answers cover the log semantics underneath.

  1. Nakadi manualnakadi.io
  2. Apache Kafka: Introduction (partitions and ordering)kafka.apache.org

Open as a standalone page

3.28 How would you design a system that processes billions of records?

The 30 second answer

I start with the questions that size everything: what one record is, how fast records arrive, how they are read, how fresh results must be, and how long data is kept. Then I partition by the key the reads use, process in parallel with idempotent, restartable steps, and pick storage per access pattern: a log for movement, a columnar or partitioned store for scans, a search index for discovery. Estimates come from variables, not invented numbers.

Public candidate reviews of Zalando interviews mention "how would you model a problem with billions of records". The question is deliberately open: the interviewer wants to see you drive requirements, estimate, and choose partitioning and storage, not recite a stack.

Clarify before drawing.

  • What is a record (a product view event, an offer, a price change) and roughly how large is it?
  • How fast do records arrive on average and at peak? Peak matters more during sale events.
  • How is the data read: point lookups by key, scans and aggregates over time ranges, or full-text and faceted search?
  • How fresh must results be: seconds, minutes, or a nightly batch?
  • How long is it kept, and is it ever deleted or corrected?

Estimate with variables. Storage is roughly record count times average record size times replication, plus index overhead. Throughput is peak records per second times processing cost per record. Writing the formula first and plugging in the interviewer's numbers avoids inventing figures, and shows which variable dominates.

Partition by the read key. Every scalable design splits data so work can proceed in parallel and a query touches as few pieces as possible.

  • In a log such as Kafka, events with the same key go to the same partition and are read in the order they were written, so key by the entity whose order matters (SKU, customer).
  • In PostgreSQL, declarative partitioning by time or key lets queries prune to the relevant partitions, and the docs point out that dropping or detaching a partition is far faster than a bulk delete and avoids the VACUUM overhead.
  • In Elasticsearch, primary shards split an index; size them deliberately rather than by default.

Process in parallel, safely. The MapReduce model, map records to key-value pairs and reduce all values for a key, still describes most batch work. For continuous data, a stream processor keyed by entity keeps per-key state (as in the Flink question above). Either way, make steps idempotent and restartable: deterministic outputs keyed by record id, checkpoints, and the ability to replay from the log.

Pick storage per access pattern.

NeedTypical choice
Moving and replaying eventsPartitioned log (Kafka, Nakadi)
Point lookups by key at scaleKey-value or partitioned relational store
Large scans and aggregatesColumnar warehouse or partitioned tables
Full-text and faceted discoverySearch index built from the log

Close with operations. Backfills and reprocessing, data quality checks, hot keys, cost per stored and processed record, and how you would observe lag end to end.

Alternate interview scenarios

"Find the top products by views over the last hour from billions of events."

Key events by product, count in a stream processor with tumbling or sliding windows, and keep a per-partition top list that a final step merges, the same partial-top-then-merge idea Elasticsearch uses for terms aggregations. If exact counts are not required, an approximate sketch reduces memory, and the trade-off should be stated explicitly.

"A full reprocess of all records is needed after a bug fix. How?"

Replay from the retained log, or from snapshots plus the log, into a new output (a new table or index) while the old one keeps serving, then switch with an alias or a view and keep the old output until checks pass. Make the job idempotent so it can be restarted from checkpoints without double counting.

  1. PostgreSQL: Table partitioningpostgresql.org
  2. Apache Kafka: Introduction (partitions and ordering)kafka.apache.org
  3. Dean and Ghemawat, MapReduce: Simplified Data Processing on Large Clusters (Google Research)research.google
  4. Elasticsearch: Size your shardselastic.co

Open as a standalone page

Coding

Binary search done right, an extensible card game simulation, and SOLID without over-engineering.

These prompts cover the usual coding ground for this loop: binary search on a list, a two-sum on a rotated sorted array, merge sort, typeahead, Dijkstra, a card-game simulation, and applying SOLID in a small design without over-engineering.

4.1 How do you write a correct binary search on an array list, including first and last occurrence?

The 30 second answer

I keep an explicit loop invariant and pick one boundary style. With inclusive bounds the loop runs while low is at most high, and the midpoint uses an unsigned shift so the sum cannot overflow. For first or last occurrence I search for a lower bound with half-open bounds. On a list I check it supports random access, because on a linked list each get walks the nodes and the search stops being logarithmic.

A public candidate review of a Zalando live-coding round mentions "binary search on an array list". It sounds easy, which is why interviewers use it: the bugs are in the boundaries.

State the invariant first. Classic search with inclusive bounds: if the key is present, it is within [lo, hi]. Lower bound with half-open bounds: everything before lo is smaller than the key and everything from hi on is at least the key. Mixing the two styles in one loop is where off-by-one errors come from.

The overflow bug. Google's research blog showed that int mid = (low + high) / 2, used in many textbook and library implementations, fails when low + high exceeds Integer.MAX_VALUE; it recommends low + ((high - low) / 2) or (low + high) >>> 1.

import java.util.*;

public class BinarySearchDemo {
    // Index of key in a sorted list, or -1. Works on any RandomAccess list such as ArrayList.
    static int indexOf(List<Integer> a, int key) {
        int lo = 0, hi = a.size() - 1;            // inclusive bounds
        while (lo <= hi) {
            int mid = (lo + hi) >>> 1;            // no int overflow
            int v = a.get(mid);
            if (v < key) lo = mid + 1;
            else if (v > key) hi = mid - 1;
            else return mid;
        }
        return -1;
    }

    // First index i with a.get(i) >= key (a "lower bound"); a.size() if none.
    static int lowerBound(List<Integer> a, int key) {
        int lo = 0, hi = a.size();                // half-open [lo, hi)
        while (lo < hi) {
            int mid = (lo + hi) >>> 1;
            if (a.get(mid) < key) lo = mid + 1; else hi = mid;
        }
        return lo;
    }

    static int firstOccurrence(List<Integer> a, int key) {
        int i = lowerBound(a, key);
        return (i < a.size() && a.get(i) == key) ? i : -1;
    }

    static int lastOccurrence(List<Integer> a, int key) {
        int i = lowerBound(a, key + 1) - 1;       // assumes key < Integer.MAX_VALUE
        return (i >= 0 && a.get(i) == key) ? i : -1;
    }

    public static void main(String[] args) {
        List<Integer> a = new ArrayList<>(List.of(1, 3, 3, 3, 7, 9));
        System.out.println(indexOf(a, 7));          // 4
        System.out.println(indexOf(a, 4));          // -1
        System.out.println(firstOccurrence(a, 3));  // 1
        System.out.println(lastOccurrence(a, 3));   // 3
        System.out.println(lowerBound(a, 8));       // 5
        System.out.println(indexOf(List.of(), 1));  // -1
        System.out.println(Collections.binarySearch(a, 4)); // -5: insertion point is 4
    }
}

Verified with OpenJDK 21.0.13: it prints 4, -1, 1, 3, 5, -1 and -5.

Why "array list" matters. ArrayList implements RandomAccess, so get(i) is constant time and the search is O(log n). The Collections.binarySearch Javadoc spells out the alternative: for a large list that does not implement RandomAccess, such as LinkedList, it does an iterator-based search with O(n) link traversals and O(log n) comparisons. Mentioning this shows you know the library, not just the algorithm.

Library behaviour worth knowing. Collections.binarySearch and Arrays.binarySearch return the index if found, otherwise -(insertion point) - 1, so the result is non-negative only when the key exists. With duplicates, the Javadoc gives no guarantee which matching index is returned, which is exactly why first and last occurrence need the lower-bound version. The list must be sorted by the same order you search with; otherwise the result is undefined.

Tests to say out loud. Empty list, one element, key smaller than all and larger than all, key absent between two elements, all elements equal, and duplicates at both ends.

Alternate interview scenarios

"Find the smallest price at or above a budget in a sorted price list."

That is a lower bound: the first index whose value is at least the budget. Use the half-open loop and check the returned index against the list size before reading it. The same building block answers "how many items cost less than X", which is simply the lower bound's index.

"Binary search on the answer: what is the minimum capacity to ship all parcels within D days?"

When a yes-or-no check is monotonic in a parameter (if capacity C works, any larger capacity works), binary search the parameter instead of an index. The bounds are the largest single parcel and the total weight, and each step runs a linear feasibility check, giving O(n log(sum)) time.

<!-- include: java-spring/zalando-java-jvm-technical-round#how-do-you-find-two-numbers-that-sum-to-k-in-a-rotated --> <!-- why: A rotated sorted array is a common twist on binary search, and the pivot logic is easy to get wrong at the boundaries. -->

<!-- include: java-spring/zalando-coding-interview-questions#how-do-you-explain-merge-sort-and-can-it-run-in-constant --> <!-- why: Merge sort is the stable, guaranteed n log n sort, and the constant-space variant is a useful complexity discussion. -->

<!-- include: java-spring/zalando-coding-interview-questions#how-would-you-implement-a-search-box-that-suggests-results --> <!-- why: Typeahead is the search-box problem: prefix lookup, ranking of suggestions, and what you do when keystrokes arrive faster than results. -->

<!-- include: java-spring/zalando-coding-interview-questions#how-does-dijkstra-s-shortest-path-algorithm-work --> <!-- why: Graph shortest paths show up in routing-style follow-ups, and you need the invariant, the heap, and the complexity in one pass. -->

<!-- include: java-spring/zalando-coding-interview-questions#what-coding-patterns-show-up-in-reported-zalando-problems --> <!-- why: A short map of the recurring patterns, two pointers, binary search, graphs, tries, ties the prompts in this chapter together. -->

  1. Google Research blog: Extra, Extra - Read All About It: Nearly All Binary Searches and Mergesorts are Brokenresearch.google
  2. Java SE 21 API: Collections.binarySearchdocs.oracle.com
  3. Java SE 21 API: RandomAccessdocs.oracle.com
  4. Java SE 21 API: Arrays.binarySearchdocs.oracle.com

Open as a standalone page

4.2 How do you find two numbers that sum to k in a rotated sorted array?

A rotated sorted array is a common twist on binary search, and the pivot logic is easy to get wrong at the boundaries.

The 30 second answer

A hash set solves it in one pass with linear time and space. To use constant space, I find the rotation point with binary search, which is the index of the smallest element. Then I run the usual two-pointer search, starting at the smallest and the largest elements and moving the pointers around the array with modulo arithmetic. That keeps linear time and constant extra space.

Reported. A 2022 Zalando backend candidate got this in the final coding round. They gave a hash-map solution first, then the two-pointer version with binary search for the rotation point, and wrote working code in a shared document.

Approach 1: hash set. For each value x, check whether k - x was seen, then add x. O(n) time, O(n) space, and it works on any array.

Approach 2: constant space. A sorted array rotated at an unknown point is still sorted if you start reading at its smallest element.

  1. Find the smallest element with binary search: if a[mid] > a[hi], the minimum is to the right of mid; otherwise it is at mid or to its left. That is O(log n) when the values are distinct.
  2. Two pointers on the circle: l starts at the smallest element and r just before it, at the largest. If the sum is too small, move l forward; if too large, move r back, both modulo n. Stop when they meet. O(n) time, O(1) space.
import java.util.*;

public class RotatedTwoSumShow {
    // Index of the smallest element in a rotated sorted array of distinct values: O(log n).
    static int minIndex(int[] a) {
        int lo = 0, hi = a.length - 1;
        while (lo < hi) {
            int mid = (lo + hi) >>> 1;
            if (a[mid] > a[hi]) lo = mid + 1;   // the minimum is to the right of mid
            else hi = mid;                      // mid could be the minimum
        }
        return lo;
    }

    // Two pointers that wrap around the rotation point: O(n) time, O(1) extra space.
    static int[] pairWithSum(int[] a, int k) {
        int n = a.length;
        if (n < 2) return null;
        int l = minIndex(a);              // smallest
        int r = (l - 1 + n) % n;          // largest
        while (l != r) {
            int sum = a[l] + a[r];
            if (sum == k) return new int[] {a[l], a[r]};
            if (sum < k) l = (l + 1) % n;
            else r = (r - 1 + n) % n;
        }
        return null;
    }

    public static void main(String[] args) {
        int[] a = {11, 15, 26, 38, 9, 10};
        System.out.println(Arrays.toString(pairWithSum(a, 35)));   // 9 + 26
        System.out.println(Arrays.toString(pairWithSum(a, 45)));   // none
        System.out.println(Arrays.toString(pairWithSum(new int[] {1, 2, 3, 4}, 7)));  // not rotated

    }
}

Verified with OpenJDK 21.0.13: it prints [9, 26], null and [3, 4]. A brute-force cross-check on 10,000 random rotated arrays found no mismatch.

Follow-ups to expect.

  • Duplicates: when a[mid] == a[hi], binary search cannot tell which side holds the minimum, so step hi back by one. The worst case becomes O(n), which still fits inside the O(n) total.
  • Count all pairs, or return indices: keep moving both pointers after a match; return positions instead of values.
  • Which to choose: say the hash set is simpler and fine unless memory matters, then offer the constant-space version. Interviewers usually want both and the trade-off.
  1. LeetCode Discuss: Zalando C6 Backend Engineer, Berlin (candidate report, May 2022)leetcode.com
  2. LeetCode 153: Find Minimum in Rotated Sorted Array (problem statement)leetcode.com

Canonical answer

4.3 How do you explain merge sort, and can it run in constant extra space?

Merge sort is the stable, guaranteed n log n sort, and the constant-space variant is a useful complexity discussion.

The 30 second answer

Standard merge sort splits the array, sorts each half, and merges them into a buffer. That is n log n time and n extra space, plus a logarithmic call stack. The running time is not logarithmic; only the recursion depth is. You can merge in constant extra space by sliding elements in place, but that merge is quadratic, so the sort slows down. Heapsort is the usual n log n alternative with constant extra memory.

Reported as a discussion in a 2023 Computer Science fundamentals hour: design and explain merge sort with constant space complexity, and discuss how the time complexity is logarithmic. The second half of that wording is not correct for the running time, and is worth unpacking carefully.

How standard merge sort works. Split the array into two halves, sort each half, then merge two sorted runs into a buffer with a two-pointer pass. Each merge of a run of length k is O(k). There are O(log n) levels, and each level touches all n elements, so the time is O(n log n), not O(log n). What is logarithmic is the recursion depth, and therefore the call-stack size, which is O(log n). The extra array used for merging is O(n).

Constant extra space. "Constant space merge sort" cannot keep that linear buffer.

  • A simple in-place merge inserts each element of the right run into the left run by sliding a gap, using O(1) extra memory besides the call stack. That merge is O(n²) in the worst case, so the sort becomes O(n² log n). It is honest and easy to write in an interview.
  • There are more sophisticated in-place merges with better time, but they are rarely something you should try to invent on a whiteboard.
  • If the real need is O(n log n) time and O(1) extra memory, heapsort is the usual algorithm to name. It is not merge sort, and it is not stable.

Bottom-up iterative merge sort still needs a linear buffer if each merge is to stay linear-time; dropping the recursion only removes the O(log n) stack.

import java.util.Arrays;

public class MergeSorts {
    static void mergeSort(int[] a) {
        if (a.length < 2) return;
        mergeSort(a, new int[a.length], 0, a.length);
    }

    static void mergeSort(int[] a, int[] buf, int lo, int hi) {
        if (hi - lo < 2) return;
        int mid = (lo + hi) >>> 1;
        mergeSort(a, buf, lo, mid);
        mergeSort(a, buf, mid, hi);
        int i = lo, j = mid, k = lo;
        while (i < mid && j < hi) buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
        while (i < mid) buf[k++] = a[i++];
        while (j < hi) buf[k++] = a[j++];
        System.arraycopy(buf, lo, a, lo, hi - lo);
    }

    /** O(1) extra memory besides the call stack; each merge is quadratic. */
    static void inPlaceMergeSort(int[] a) { inPlaceMergeSort(a, 0, a.length); }

    static void inPlaceMergeSort(int[] a, int lo, int hi) {
        if (hi - lo < 2) return;
        int mid = (lo + hi) >>> 1;
        inPlaceMergeSort(a, lo, mid);
        inPlaceMergeSort(a, mid, hi);
        int i = lo, j = mid;
        while (i < j && j < hi) {
            if (a[i] <= a[j]) i++;
            else {
                int val = a[j];
                System.arraycopy(a, i, a, i + 1, j - i);
                a[i] = val;
                i++;
                j++;
            }
        }
    }

    public static void main(String[] args) {
        int[] a = {5, 1, 4, 2, 8, 0};
        int[] b = a.clone();
        mergeSort(a);
        inPlaceMergeSort(b);
        System.out.println(Arrays.toString(a));
        System.out.println(Arrays.toString(b));
    }
}

Verified with OpenJDK 21.0.13: both paths print [0, 1, 2, 4, 5, 8]. The same two sorts were checked against Arrays.sort on random arrays of length 0 through 40; there was no mismatch.

Follow-ups. Stability (standard merge sort is stable; the in-place slide above is also stable; heapsort is not); why the running time is n log n rather than logarithmic; iterator / linked-list merge sort, which can merge with O(1) extra object pointers because the input is already a chain of nodes.

  1. LeetCode Discuss: Zalando Senior Software Engineer, Berlin (candidate report, April 2023)leetcode.com

Canonical answer

4.4 How would you implement a search box that suggests results on every keystroke?

Typeahead is the search-box problem: prefix lookup, ranking of suggestions, and what you do when keystrokes arrive faster than results.

The 30 second answer

The reported task is a search box that fetches suggestions on every keystroke. The important parts are a complete happy path, tests, clear names, and not calling the backend on every key. I debounce the input so a request goes out only after the user pauses, and I ignore stale responses so a slow earlier query cannot overwrite a later one. A short cache and a minimum query length are the usual next optimizations.

Reported. A 2024 frontend Senior Software Engineer take-home on Codility asked for a React and Node application: a search box with an auto-suggestion list on every keystroke. The write-up says the bar was the required features, unit tests with several scenarios, comments, meaningful names, and a performance habit — debounce on the keystroke handler. The later discussion asked what happens if imports are removed, how to optimize further, and whether you can write your own debounce.

This is a frontend exercise, included because it is a fully described Zalando coding task. On a Java backend interview the same performance idea shows up as "don't call the search API on every key."

Approach.

  1. Debounce the query. After each keystroke, reset a short timer (for example 200–300 ms, Inferred — the report did not name a delay). Only when the user pauses do you send the request. That cuts traffic and load on the suggest endpoint.
  2. Ignore stale responses. Keystrokes 1, 2, 3 can be in flight together. If the response for "sa" arrives after the response for "sal", the list would jump backwards. Give every request a monotonic id, or abort the previous fetch, and only publish a result when it still matches the latest query.
  3. Tests. Typeahead, empty query, no hits, a delayed earlier response, and the debounce itself (rapid keystrokes produce one call).
  4. Further optimizations, which match the reported follow-up: a minimum query length, an in-memory cache keyed by the normalized string, request cancellation, and pagination of the suggestion list.

A small Java debounce and stale-result guard, compiled and run on OpenJDK 21.0.13, behaves as follows: three tasks submitted 20 ms apart, with an 80 ms delay, run only the last one; after a second request id is issued, the first id is no longer current.

import java.util.concurrent.*;

final class Debouncer implements AutoCloseable {
    private final ScheduledExecutorService exec =
            Executors.newSingleThreadScheduledExecutor();
    private final long delayMs;
    private ScheduledFuture<?> pending;

    Debouncer(long delayMs) { this.delayMs = delayMs; }

    synchronized void submit(Runnable task) {
        if (pending != null) pending.cancel(false);
        pending = exec.schedule(task, delayMs, TimeUnit.MILLISECONDS);
    }

    @Override public void close() { exec.shutdownNow(); }
}

final class StaleGuard {
    private long latest;
    synchronized long nextId() { return ++latest; }
    synchronized boolean isCurrent(long id) { return id == latest; }
}

Edge cases. Empty and very short queries; the user clearing the box; duplicate keys; a failed request that must not block the next one; accessibility of the suggestion list (keyboard, screen reader) on a frontend implementation.

  1. LeetCode Discuss: Zalando Senior Software Engineer (frontend) take-home (candidate report, July 2024)leetcode.com

Canonical answer

4.5 How does Dijkstra's shortest-path algorithm work?

Graph shortest paths show up in routing-style follow-ups, and you need the invariant, the heap, and the complexity in one pass.

The 30 second answer

Dijkstra finds shortest paths from one source in a graph with non-negative edge weights. I keep a distance array, start the source at zero, and repeatedly settle the unsettled node with the smallest distance, relaxing its outgoing edges. With a binary heap the time is on the order of E plus V times log V. Negative edges are not allowed; those need Bellman-Ford. Unweighted graphs can use a plain breadth-first search instead.

Reported as a discussion of a shortest-path algorithm (Dijkstra) in a 2023 hiring-manager conversation, not as a timed coding prompt with a custom graph. The answer below is the standard algorithm, with a Java implementation that was compiled and run.

Problem. Given a directed or undirected graph with non-negative edge weights, a source vertex, and V vertices, compute the shortest-path distance from the source to every vertex, or report that a vertex is unreachable.

Approach. Keep a distance array, with the source at 0 and every other vertex at infinity. Repeatedly settle the unsettled vertex u with the smallest distance, then relax each outgoing edge u → v of weight w: if dist[u] + w < dist[v], update dist[v]. A binary heap (Java PriorityQueue) finds the next vertex in logarithmic time. The common lazy version inserts a new heap entry when a distance improves, and ignores stale entries when they are popped. Time is O((V + E) log V) with a binary heap. Extra space is O(V + E).

Dijkstra is wrong if any edge weight is negative; use Bellman-Ford, which allows negatives and can detect a negative cycle, at O(V E). If every edge has the same weight, a plain BFS already finds shortest paths, in O(V + E), and you should not use a heap.

import java.util.*;

public class Dijkstra {
    record Edge(int to, int weight) {}

    static int[] dijkstra(int n, List<List<Edge>> g, int src) {
        int[] dist = new int[n];
        Arrays.fill(dist, Integer.MAX_VALUE);
        dist[src] = 0;
        PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(x -> x[1]));
        pq.add(new int[] {src, 0});
        boolean[] done = new boolean[n];
        while (!pq.isEmpty()) {
            int[] cur = pq.poll();
            int u = cur[0];
            if (done[u]) continue;
            done[u] = true;
            for (Edge e : g.get(u)) {
                if (e.weight < 0) throw new IllegalArgumentException("negative weight");
                if (dist[u] != Integer.MAX_VALUE && dist[u] + e.weight < dist[e.to]) {
                    dist[e.to] = dist[u] + e.weight;
                    pq.add(new int[] {e.to, dist[e.to]});
                }
            }
        }
        return dist;
    }

    public static void main(String[] args) {
        List<List<Edge>> g = new ArrayList<>();
        for (int i = 0; i < 4; i++) g.add(new ArrayList<>());
        g.get(0).add(new Edge(1, 4));
        g.get(0).add(new Edge(2, 1));
        g.get(2).add(new Edge(1, 1));
        g.get(1).add(new Edge(3, 1));
        g.get(2).add(new Edge(3, 5));
        System.out.println(Arrays.toString(dijkstra(4, g, 0))); // [0, 2, 1, 3]
    }
}

Verified with OpenJDK 21.0.13: it prints [0, 2, 1, 3]. The path 0 → 2 → 1 → 3 has length 3, which beats the direct 0 → 1 edge of weight 4 and the 0 → 2 → 3 edge of weight 6. A three-node graph with no edge into vertex 2 leaves that distance at Integer.MAX_VALUE.

Follow-ups. Reconstruct the path by storing predecessors; why a decrease-key Fibonacci heap is rarely worth it in practice; A* as Dijkstra with a heuristic; int overflow on dist[u] + w when weights are large (use long).

  1. LeetCode Discuss: Zalando Senior Software Engineer, Berlin (candidate report, April 2023)leetcode.com

Canonical answer

4.6 What coding patterns show up in reported Zalando problems, and how should you solve them?

A short map of the recurring patterns, two pointers, binary search, graphs, tries, ties the prompts in this chapter together.

The 30 second answer

The reported algorithmic problems sit in the Easy to Medium band: strings, hash maps, two pointers, binary search, greedy simulation, interval union, and light graph work such as flood fill. Later rounds also want you to clarify the prompt, write tests, and keep a small design simple. I restate the rules and edge cases, ship a correct solution, then tighten time and memory. Hidden tests punish missed edges, so I try empty input, no match, and the failure cases.

Problem types, from problems that were actually stated or named.

  • Strings and scanning: contact search by a phone-number substring; constructing a string from AA / BB / AB.
  • Hashing and two pointers: the hash-set then constant-space solution for two-sum in a rotated sorted array, already answered at How do you find two numbers that sum to k in a rotated sorted array?.
  • Binary search: that same rotation-point step, and a live-coding item described only as "binary search on an array list."
  • Greedy simulation: ice-cream change; the AA / BB / AB construction; a card-picking game whose rules were not remembered, with a request to keep the design extensible and not more complex than the moment required.
  • Intervals: covered time as a union length, in the spirit of merging intervals.
  • Arrays / filtering: sum of two-digit integers, a warm-up scan.
  • Light graphs: flood fill as a small BFS or DFS; Dijkstra as a shortest-path discussion.
  • Practical application work: a search box with autosuggest, judged on tests, names, and debounce. One backend online test also had a Java slot and a SQL slot next to a DSA question; the statements were not published.

Trees, heaps and heavy DP are not the problems people wrote down most often. A "DP string question" was named on one thread with no statement, so it is not expanded into a made-up prompt here.

Difficulty. Across public reports and a former interviewer's write-up, the algorithmic work sits in the Easy to Medium band, not Hard-level contest problems. That is a statement about the DSA, not a claim that the interview is easy: clarification, tests, and a simple design are part of the bar.

How to solve under a time limit. Zalando's own software-engineer interview post asks you to clarify anything that is unclear, write code in a live environment, and discuss trade-offs, data-structure choices, and time and memory complexity. It recommends the REACTO strategy. A practical order that matches that rubric:

  1. Restate the rules and ask about edges before you type: empty input, no match, duplicates, open versus closed interval ends, what to return when several answers are valid.
  2. Ship a correct solution first, even if it uses extra memory. The rotated-array two-sum report did this: a hash set in linear time and space, then a tighter two-pointer pass.
  3. Then tighten time and memory, and say the complexity out loud.
  4. Treat hidden tests as part of the problem. Online assessments run code against cases you do not see. Before you stop, try the empty case, the "NO CONTACT" case, a twenty that needs three fives, touching intervals, and a start color that already equals the fill color.
  5. When the prompt is a small feature or a simulation, write tests and keep the design small. A 2025 coding interview was described as expecting requirement clarification, tests, and explicit error handling, in the way you would write production code, rather than only a pasted LeetCode solution. A 2023 simulation task asked for an extensible design and later feedback was that a more complex design was not required at that point. Start with a clear model and tests; add abstraction when a follow-up needs it.

What this page does not turn into a full question, and why.

  • Two-sum in a rotated sorted array: already published, linked above.
  • Missing dice rolls: the 2020 Codility report only linked an Amazon online-assessment thread, and that thread could not be recovered, so the statement was not guessed.
  • "Reverse a string," "binary search on an array list," and "a DP string question": named only, with no twist or statement.
  • A card-picking game whose rules the author no longer remembered: the useful technical residue is "simulate the rules, keep the design simple, and make it extensible when asked."
  • "Two easy algorithms plus an AWS Lambda implementation": no algorithm statements.
  1. Zalando Jobs: Technical interview for Software Engineers (July 2023)jobs.zalando.com
  2. Zalando Engineering: Interview preparation tips for Java developers (June 2016)engineering.zalando.com
  3. CleverCoder: Zalando: How to pass the interview (former interviewer, April 2018)clevercoder.net
  4. LeetCode Discuss: Zalando Germany Codility test (candidate report, November 2020)leetcode.com
  5. LeetCode Discuss: Zalando C6 Backend Engineer, Berlin (candidate report, May 2022)leetcode.com
  6. LeetCode Discuss: Zalando Senior Software Engineer, Berlin (candidate report, April 2023)leetcode.com
  7. LeetCode Discuss: Interview Experience Zalando (candidate report, August 2025)leetcode.com
  8. LeetCode Discuss: Zalando Senior Software Engineer (frontend) take-home (candidate report, July 2024)leetcode.com

Canonical answer

4.7 How would you build an extensible card game simulation without over-designing it?

The 30 second answer

I clarify the rules first, then model only what they need: a card record, a deck with an injected random generator so tests are deterministic, players, and a game loop. I add one extension point where the interviewer says rules will vary, such as how a round is won. I write a fixed-deck test first. A candidate review of this exact prompt reports feedback that the design was more complex than needed.

A public candidate review of a senior Zalando coding hour describes "a simulation program for a card picking game" with a request to make the design extensible, and later feedback that the design had been made more complex than required at that point. The review does not give the full rules, so the rules below are an assumed example. In the interview, ask for the rules before writing anything.

Clarify. How many players, which deck, how cards are picked, how a round is won, how ties break, when the game ends, and what output is expected. Then ask which part is most likely to change; that is where the one extension point goes.

Assumed rules for this example. Two or more players, a standard 52-card deck, each round every player takes the top card, the highest rank wins and suit breaks ties, and the game ends when the deck cannot serve every player.

import java.util.*;
import java.util.random.RandomGenerator;

public class CardGame {
    enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }
    record Card(int rank, Suit suit) {            // rank 2..14 (14 = ace)
        Card {
            if (rank < 2 || rank > 14) throw new IllegalArgumentException("rank " + rank);
            Objects.requireNonNull(suit);
        }
    }

    /** The one extension point the prompt asked for: how a round is won. */
    interface RoundRule { int winner(List<Card> played); }

    static final RoundRule HIGHEST_CARD = played -> {
        Comparator<Card> byRankThenSuit =
                Comparator.comparingInt(Card::rank).thenComparing(Card::suit);
        int best = 0;
        for (int i = 1; i < played.size(); i++) {
            if (byRankThenSuit.compare(played.get(i), played.get(best)) > 0) best = i;
        }
        return best;
    };

    static Deque<Card> shuffledDeck(RandomGenerator rnd) {
        List<Card> cards = new ArrayList<>();
        for (Suit s : Suit.values()) for (int r = 2; r <= 14; r++) cards.add(new Card(r, s));
        for (int i = cards.size() - 1; i > 0; i--) {   // Fisher-Yates with an injected generator
            Collections.swap(cards, i, rnd.nextInt(i + 1));
        }
        return new ArrayDeque<>(cards);
    }

    /** Plays until the deck cannot serve every player; returns rounds won per player. */
    static int[] play(int players, Deque<Card> deck, RoundRule rule) {
        if (players < 2) throw new IllegalArgumentException("need at least two players");
        int[] wins = new int[players];
        while (deck.size() >= players) {
            List<Card> played = new ArrayList<>(players);
            for (int p = 0; p < players; p++) played.add(deck.pop());
            wins[rule.winner(played)]++;
        }
        return wins;
    }

    public static void main(String[] args) {
        // Deterministic test: a fixed deck makes the outcome checkable.
        Deque<Card> fixed = new ArrayDeque<>(List.of(
                new Card(10, Suit.HEARTS), new Card(14, Suit.CLUBS),     // round 1: player 1
                new Card(5, Suit.SPADES), new Card(5, Suit.CLUBS),       // round 2: tie on rank, spades wins -> player 0
                new Card(2, Suit.HEARTS)));                              // leftover card, not dealt
        System.out.println(Arrays.toString(play(2, fixed, HIGHEST_CARD)));   // [1, 1]

        int[] wins = play(4, shuffledDeck(RandomGenerator.of("L64X128MixRandom")), HIGHEST_CARD);
        System.out.println(Arrays.stream(wins).sum());                        // 13 rounds for 52 cards
        try { play(1, fixed, HIGHEST_CARD); } catch (IllegalArgumentException e) { System.out.println(e.getMessage()); }
    }
}

Verified with OpenJDK 21.0.13: it prints [1, 1], 13 and need at least two players.

Design choices to explain.

  • Records and enums for immutable values. A record (JEP 395) gives equality and a compact constructor for validation in a few lines.
  • Injected randomness. The deck takes a RandomGenerator, so a test can pass a fixed deck or a seeded generator and assert exact results. Hidden calls to a global random source make simulations untestable.
  • One extension point. RoundRule is the only interface, because "how a round is won" is the rule most likely to vary. Turn order, scoring and end conditions stay plain code until the interviewer asks for them to vary too.
  • Errors at the boundary. Invalid ranks and fewer than two players fail fast with clear messages, which a later Zalando candidate review describes interviewers looking for: requirement clarification, tests, and error handling as in production code.

Growing it on request. If the interviewer adds rules (jokers, a discard pile, players with strategies), add a Player type with a strategy interface at that point, or a sealed interface for card kinds (JEP 409) so a switch over them stays exhaustive. Each addition should answer a stated requirement.

Alternate interview scenarios

"The interviewer says: write it like production code, test first."

I would write the fixed-deck test before the game loop, name the rule being tested, then implement until it passes, and add tests for ties, an empty deck and invalid input. I would say why each check exists and keep methods small enough to test alone, instead of building a framework.

"Now make the winning rule configurable per game."

Pass a different RoundRule, for example lowest card wins or a trump suit that beats any other suit. Because the game loop already depends on the interface, no existing code changes, which is the open-closed principle in practice and the reason that single interface was worth adding.

  1. Zalando Jobs blog: Technical interview for software engineersjobs.zalando.com
  2. Java SE 21 API: RandomGeneratordocs.oracle.com
  3. JEP 395: Recordsopenjdk.org
  4. JEP 409: Sealed Classesopenjdk.org

Open as a standalone page

4.8 How do you apply SOLID in a small Java design, and when does it become over-engineering?

The 30 second answer

I treat SOLID as reasons to split or not split code, not a checklist. Single responsibility means one reason to change, tied to one group of people. Open-closed means adding behaviour without editing stable code, but only where change is expected. Liskov means implementations never surprise callers of the interface. Interface segregation keeps interfaces small, and dependency inversion keeps business rules independent of details. Abstractions need a real second case.

Public candidate reviews of Zalando interviews list SOLID next to merge sort and maps. Interviewers rarely want definitions alone; they want to see the principles applied, and to hear when you would not apply them.

The principles, in the author's words. Robert C. Martin's own summary:

  • Single responsibility. "Each software module should have one and only one reason to change." He adds that the principle "is about people": a module should answer to one group of stakeholders. Another wording: "Gather together the things that change for the same reasons. Separate those things that change for different reasons."
  • Open-closed. "A Module should be open for extension but closed for modification."
  • Liskov substitution. "A program that uses an interface must not be confused by an implementation of that interface." Martin stresses that it is about subtyping, not only inheritance.
  • Interface segregation. "Keep interfaces small so that users don't end up depending on things they don't need."
  • Dependency inversion. "Depend in the direction of abstraction. High level modules should not depend upon low level details."

Applied to a search example. A SearchService that parses the request, builds an Elasticsearch query, calls the client, re-ranks and formats JSON has several reasons to change. Split by who asks for changes: query building (search relevance engineers), ranking (data scientists), transport and formatting (API owners). The service depends on a ProductIndex interface rather than the Elasticsearch client (dependency inversion), so a client upgrade or a test fake does not touch ranking. A RankingStrategy interface is open for new strategies in A/B tests without editing the service (open-closed). Every strategy must return the same products it was given, reordered, never dropping or adding items silently (Liskov). Callers that only read suggestions get a small Suggester interface, not the whole index API (interface segregation).

When it becomes over-engineering. An interface with one implementation and no test seam, factories for objects that never vary, and layers that only pass calls through all add indirection without a reason to change behind them. Martin's own answer to "just write simple code" is that he agrees, and that the principles are a way to keep code simple. The useful rule: add an abstraction when there is a second real case, a test that needs a seam, or a stated likely change. The card game question above shows that judgement in a live exercise.

Alternate interview scenarios

"Give an example of a Liskov violation."

A read-only product view that implements a MutableCatalog interface and throws UnsupportedOperationException from update breaks callers that rely on update working. Java's own unmodifiable collections make this trade-off deliberately, and document it as an optional operation; in your own domain, prefer two interfaces, one for reading and one for writing.

"A payment or pricing class has a growing if-else on type. Which principle helps?"

Open-closed with a strategy per type, selected by a map or by a sealed interface with an exhaustive switch. Each new type is a new class, not an edit to a growing method, and each strategy can be tested alone.

  1. Robert C. Martin: The Single Responsibility Principleblog.cleancoder.com
  2. Robert C. Martin: Solid Relevanceblog.cleancoder.com
  3. JEP 409: Sealed Classesopenjdk.org

Open as a standalone page

Sources

  1. Zalando Engineering: Migrating From Elasticsearch 7.17 to Elasticsearch 8.x: Pitfalls and Learningsengineering.zalando.com
  2. Zalando Engineering: The Day Our Own Queries DoS'ed Us: Inside Zalando Searchengineering.zalando.com
  3. Zalando Engineering: Why We Ditched Flink Table API Joins: Cutting State by 75% with DataStream Unionsengineering.zalando.com
  4. Zalando Engineering: Interview preparation tips for Java developersengineering.zalando.com
  5. Zalando Jobs blog: Technical interview for software engineersjobs.zalando.com
  6. Nakadi manualnakadi.io
  7. Elasticsearch: Term queryelastic.co
  8. Elasticsearch: Multi-fieldselastic.co
  9. Elasticsearch: Nested field typeelastic.co
  10. Elasticsearch: Join field typeelastic.co
  11. Elasticsearch: Flattened field typeelastic.co
  12. Elasticsearch: Tune for indexing speedelastic.co
  13. Elasticsearch: Size your shardselastic.co
  14. Elasticsearch: Quorum-based decision makingelastic.co
  15. Elasticsearch: kNN searchelastic.co
  16. Elasticsearch: Terms aggregationelastic.co
  17. Elasticsearch: Search settingselastic.co
  18. Elasticsearch: Circuit breaker settingselastic.co
  19. Elasticsearch: Shard request cacheelastic.co
  20. Elasticsearch: Aliaseselastic.co
  21. Elasticsearch 8.19: Upgrade Elasticsearchelastic.co
  22. Apache Flink 1.20: Joins in SQLnightlies.apache.org
  23. Apache Kafka: Introductionkafka.apache.org
  24. PostgreSQL: Table partitioningpostgresql.org
  25. Oracle Java SE 21 Troubleshooting Guide: Troubleshoot Memory Leaksdocs.oracle.com
  26. Oracle Java SE 21: Native Memory Trackingdocs.oracle.com
  27. Java SE 21 API documentation (java.util)docs.oracle.com
  28. Google Research blog: Nearly All Binary Searches and Mergesorts are Brokenresearch.google
  29. Robert C. Martin: Solid Relevanceblog.cleancoder.com