Design a producer-consumer pipeline with a bounded buffer. What do you do when the buffer is full?
The bound is the design, not a detail: it is what applies backpressure to the producer instead of letting an unbounded queue absorb an overload until the process dies. Deciding what a full buffer does to the producer - block, drop, or reject - is the decision that determines how the system fails.
What the interviewer is scoring
- Whether the candidate treats the bound as backpressure rather than as memory protection alone
- That the full-buffer policy is presented as a product decision with named alternatives
- Does the candidate use a condition variable in a loop and explain why an if statement is wrong
- Whether shutdown and draining are addressed, including how consumers learn to stop
- That a poisoned or repeatedly failing item is considered
Answer
The bound is the whole design
An unbounded queue looks like the safe default. It never rejects anything, producers never wait, and it works perfectly until the day consumers fall behind — at which point the queue absorbs the imbalance, the heap fills, and the process dies with every accepted item lost.
That is the failure mode a bound exists to prevent, and preventing memory exhaustion is only half the reason. The other half is that a full buffer is information. It is the only signal the producer gets that the downstream cannot keep up. An unbounded queue does not remove the mismatch between production and consumption rates; it hides it until the hiding stops working, and it converts a small recoverable problem into a total loss.
So the first question to answer, before any code, is what a full buffer does to the producer. That single choice determines how the system behaves under overload.
Three policies, three different systems
Block the producer. The producer waits until space appears, so production is throttled to the consumption rate automatically. Correct when the producer is something you control and slowing it is acceptable — reading a file, draining an upstream queue. Wrong when the producer is a request handler, because blocking it means holding a client connection open while a thread does nothing, and under sustained overload you exhaust the request thread pool. The queue stopped overflowing and the server stopped answering, which is not an improvement.
Drop. Discard the oldest or the newest and carry on. Correct when data has an expiry and a newer item supersedes an older one: metrics samples, position updates, sensor readings. Dropping the oldest is usually right for those, since the freshest reading is the valuable one. Never acceptable for anything you have acknowledged to a caller.
Reject. Refuse the item and tell the producer immediately. This is the right answer for a service boundary — a fast, explicit failure that lets the caller retry, shed load, or report to a user, which is far better than a slow success that arrives after everyone stopped caring. It is the shape behind HTTP 429 and behind a thread pool's rejection handler.
Choosing among these is a product decision, and it is what the question is really testing. A candidate who reaches immediately for a blocking queue without discussing what blocking does to the producer has given an implementation, not a design.
Using the platform
In any modern language the correct answer is the library's bounded queue. ArrayBlockingQueue in Java gives you all three policies: put blocks, offer returns false, offer with a timeout waits and then gives up. It is correct, it has been correct for twenty years, and writing your own is a way to introduce a bug.
You will still be asked to implement one, because the question is about whether you understand what the library does.
public final class BoundedBuffer<T> {
private final Queue<T> items = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public BoundedBuffer(int capacity) { this.capacity = capacity; }
public void put(T item) throws InterruptedException {
lock.lock();
try {
// A while loop, not an if. Waking up is not proof that the
// condition holds: another producer may have taken the space,
// and spurious wakeups are permitted by the specification.
while (items.size() == capacity) notFull.await();
items.add(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) notEmpty.await();
T item = items.remove();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
Two things there are load-bearing. The while rather than if is the point of the whole exercise: a thread that returns from await has reacquired the lock but has no guarantee the condition it waited on is still true, because another thread may have run in between. Using if produces a buffer that overflows or a remove on an empty queue, rarely, under load, which is the worst kind of bug to own.
The second is having two conditions rather than one. With a single condition you must wake every waiter, and a producer waking another producer achieves nothing but contention. Separate conditions mean signal wakes a thread that can actually proceed.
Shutdown is not an afterthought
The part most designs omit. Consumers blocked in take will block forever when producers stop, because nothing distinguishes "nothing yet" from "nothing ever again".
Two mechanisms are conventional. A poison pill — a sentinel value pushed once per consumer that means stop — flows through the queue in order, so consumers finish everything ahead of it before exiting. That ordering is the advantage, and it is why a poison pill drains cleanly while interruption does not. Interruption is faster and abandons in-flight work, which is what you want on an urgent stop but not on a graceful one.
Whichever you choose, decide explicitly whether shutdown drains the buffer or discards it, because those are different guarantees to the producer that already got an acknowledgement.
Two operational realities
A failing item will otherwise loop forever. If a consumer takes an item, throws, and returns it to the queue, that item is now an infinite loop consuming a worker. Bound the attempts and move it to a dead-letter collection where a human can look at it.
And instrument the depth. Queue depth is the single most informative metric this design produces: consistently near zero means consumers are keeping up, consistently near capacity means they are not and you are one traffic increase away from applying backpressure to production. It is the number that tells you which side of the pipeline to fix, and without it you are guessing.
An unbounded queue does not solve the rate mismatch, it postpones it and then loses everything. The bound is what turns an invisible imbalance into a signal you can act on.
Likely follow-ups
- Why must a wait on a condition variable be inside a while loop rather than an if?
- Your consumers are stuck and the buffer is full. How would you tell that apart from a slow producer?
- How do you shut this down cleanly without losing items already accepted?
- One item throws every time it is processed. What happens to the pipeline?
Related questions
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on concurrency7 min
- Design a rate limiter that allows N requests per client per minute. Write the class.mediumAlso on concurrency4 min
- Several producers write to one channel and one consumer reads it. Who closes the channel?mediumAlso on concurrency4 min
- A family plan shares 100 GB across five SIMs with each SIM capped at 30 GB. How does charging enforce both limits at once?hardAlso on concurrency6 min
- A dependency that normally answers in 80ms starts taking eight seconds. What in your service reacts, and in what order?hardAlso on backpressure5 min
- What does the GIL actually prevent, and how do you decide between threads, processes, and asyncio?mediumAlso on concurrency4 min
- Two customers try to buy the last item at the same time. How do you handle inventory?hardAlso on concurrency6 min
- What problem do virtual threads actually solve, and when do they not help?mediumAlso on concurrency5 min