The .NET `Queue<T>` and `Stack<T>` APIs, their circular-buffer and array internals, and the priority and concurrent alternatives.
Queue<T> is FIFO (first in, first out) and Stack<T> is LIFO (last in, first out). Both are backed by an array and give O(1) Enqueue/Dequeue and Push/Pop (amortised, since the array grows). Queue<T> uses a circular buffer with head and tail indexes so dequeuing never shifts elements. Dequeue, Pop and Peek throw InvalidOperationException when empty; TryDequeue, TryPop and TryPeek return false instead. A stack enumerates from the top down. Use PriorityQueue<TElement,TPriority> (.NET 6+) when the next item depends on priority, and ConcurrentQueue<T> or Channel<T> when several threads produce or consume.
Queue<T> is FIFO (first in, first out) and Stack<T> is LIFO (last in, first out). Both are backed by an array, give O(1) amortised add/remove at their ends, and are not thread-safe. I'd describe the storage, the API including the Try methods, enumeration order, and then when to switch to PriorityQueue<TElement,TPriority>, ConcurrentQueue<T> or Channel<T>.
Stack<T>: an array plus a count. Push writes at array[count++]; Pop reads array[--count]. Growth doubles the array (like List<T>).Queue<T>: a circular buffer — an array with _head, _tail and _size. Enqueue writes at _tail and advances it (wrapping to 0 at the end); Dequeue reads at _head and advances it. When full, it allocates a larger array and copies the elements in order (unwrapping the ring).Because both use arrays, they are compact and cache-friendly; there is no per-element node allocation (unlike a linked list).
| Operation | Queue<T> |
Stack<T> |
Cost |
|---|---|---|---|
| Add | Enqueue(x) |
Push(x) |
Amortised O(1) |
| Remove + return | Dequeue() |
Pop() |
O(1); throws InvalidOperationException if empty |
| Look without removing | Peek() |
Peek() |
O(1); throws if empty |
| Safe versions | TryDequeue(out x), TryPeek(out x) |
TryPop(out x), TryPeek(out x) |
O(1), return bool (.NET Core 2.0+) |
| Count | Count |
Count |
O(1) |
| Search | Contains(x) |
Contains(x) |
O(n) |
| Capacity | EnsureCapacity, TrimExcess |
same |
Prefer the Try methods when emptiness is a normal condition; using Count > 0 + Dequeue is fine single-threaded but racy if anything else touches the collection.
foreach over a Queue<T> yields items in dequeue order (oldest first) without removing them.foreach over a Stack<T> yields items in pop order (most recent first).ToArray() follows the same order. Note: new Stack<T>(otherStack) reverses the order, because it pushes items in the enumeration order of the source — a common gotcha when copying stacks.StackOverflowException on deep data), undo/redo, expression parsing (shunting-yard), matching brackets, backtracking.PriorityQueue<TElement,TPriority> (.NET 6)When the next item to process should be the most important, not the oldest:
var queue = new PriorityQueue<Job, int>(); // min-priority: smallest priority dequeued first
queue.Enqueue(new Job("refund"), priority: 1);
queue.Enqueue(new Job("report"), priority: 5);
while (queue.TryDequeue(out var job, out var priority)) Run(job);
Enqueue and Dequeue are O(log n), Peek is O(1).(priority, sequence) tuple) if FIFO among equals matters.Contains, no update-priority operation; enumeration via UnorderedItems is unordered.new PriorityQueue<T, int>(Comparer<int>.Create((a, b) => b.CompareTo(a))).Queue<T> and Stack<T> are not thread-safe. For producer/consumer scenarios:
ConcurrentQueue<T> / ConcurrentStack<T>: lock-free, use TryDequeue/TryPop.Channel<T> (System.Threading.Channels): async producer/consumer with backpressure (bounded channels), await reader.ReadAsync(), completion signalling — the modern default for in-process pipelines and BackgroundService work queues.A background export processor used a static Queue<ExportRequest> filled by API requests and drained by a hosted service with if (queue.Count > 0) queue.Dequeue(). Under load, requests were occasionally lost or processed twice, and once an InvalidOperationException ("Queue empty") crashed the hosted service, silently stopping all exports. We replaced it with a bounded Channel<ExportRequest> (BoundedChannelFullMode.Wait for backpressure), a BackgroundService reading with await foreach (var r in reader.ReadAllAsync(ct)), and error handling per item. Because exports were business-critical, we later moved to Azure Service Bus so pending work survived deployments.
Checks basic collection knowledge and whether the candidate thinks about empty cases and ordering.
PriorityQueue and concurrent options.Queue<T> is thread-safe.Queue<T>capacity 8, after enqueuing A..F and dequeuing A, B:
index: 0 1 2 3 4 5 6 7
[ ] [ ] [C] [D] [E] [F] [ ] [ ]
^head ^tail
enqueue G, H, I (I wraps to index 0):
[I] [ ] [C] [D] [E] [F] [G] [H]
^tail ^head
Enqueue: _array[_tail] = item; MoveNext(ref _tail); _size++, where MoveNext wraps to 0 at the end (implemented with a compare rather than % for speed).Dequeue: read _array[_head], clear the slot if T contains references (so the GC can collect the object), advance _head._size == _array.Length, allocate a larger array (2×, minimum +4) and copy the two segments (_head..end and 0.._tail) so the new array starts at index 0.Without the ring buffer, dequeuing from the front of an array would require shifting (O(n)) as List<T>.RemoveAt(0) does.
Stack<T> and Queue<T> clear slotsWhen T is a reference type (or a struct containing references), popped/dequeued slots are set to default. Otherwise the backing array would keep removed objects reachable and prevent garbage collection — a subtle memory leak in long-lived queues. For unmanaged T, the runtime skips clearing for speed (RuntimeHelpers.IsReferenceOrContainsReferences<T>()).
PriorityQueue stores a complete tree in an array. For a 4-ary heap, the children of node i are at 4i + 1 .. 4i + 4, and the parent at (i − 1) / 4.
Enqueue: append at the end, then sift up (swap with parent while smaller) — O(log₄ n).Dequeue: take the root, move the last element to the root, then sift down (swap with the smallest child) — O(log₄ n) levels, each comparing up to 4 children.A 4-ary heap is shallower than a binary heap (fewer levels, fewer cache misses) at the cost of more comparisons per level; .NET chose arity 4 after benchmarking. EnqueueRange and the constructor from a collection use heapify (Floyd's algorithm), O(n), which is faster than n enqueues.
EnqueueDequeue and DequeueEnqueue combine operations efficiently — useful for maintaining a bounded top-N heap.
// Keep the 10 largest orders seen in a stream
var heap = new PriorityQueue<Order, decimal>(); // min-heap by total
foreach (var o in orders)
{
if (heap.Count < 10) heap.Enqueue(o, o.Total);
else if (heap.TryPeek(out _, out var smallest) && o.Total > smallest) heap.DequeueEnqueue(o, o.Total);
}
O(n log 10) time and O(10) memory, versus sorting everything (O(n log n), O(n) memory).
static IEnumerable<Node> Bfs(Node root)
{
var queue = new Queue<Node>([root]);
var seen = new HashSet<Node>([root]);
while (queue.TryDequeue(out var node))
{
yield return node;
foreach (var child in node.Children)
if (seen.Add(child)) queue.Enqueue(child);
}
}
Replacing the Queue with a Stack turns it into depth-first traversal. Using an explicit stack avoids recursion depth limits — important because StackOverflowException cannot be caught and terminates the process.
ConcurrentQueue<T> uses linked segments (arrays) with lock-free head/tail indexes using Interlocked operations; it scales well under contention and never blocks.Channel<T> builds on similar structures plus async waiters: ReadAsync completes immediately if data is available, otherwise returns a pending ValueTask completed by the next writer. Bounded channels make writers wait (or drop items, depending on BoundedChannelFullMode), giving backpressure so a fast producer can't exhaust memory.Queue<T> and Stack<T> and compare visit order.new Stack<T>(stack) and compare the enumeration order.PriorityQueue and observe the dequeue order; fix it with a (priority, sequence) tuple.Channel<int> with a slow consumer and watch the producer wait.// FIFO work list
var work = new Queue<string>();
work.Enqueue("a.csv");
work.Enqueue("b.csv");
while (work.TryDequeue(out var file))
Console.WriteLine($"Processing {file}"); // a.csv, then b.csv
// LIFO: iterative depth-first walk, no recursion
var pending = new Stack<string>();
pending.Push(rootFolder);
while (pending.TryPop(out var dir))
{
foreach (var sub in Directory.EnumerateDirectories(dir))
pending.Push(sub);
}
// Constructor order surprise
var s = new Stack<int>([1, 2, 3]);
Console.WriteLine(s.Pop()); // 3
Console.WriteLine(string.Join(",", s)); // 2,1
// Priority: lowest value comes out first
var jobs = new PriorityQueue<string, int>();
jobs.Enqueue("email", 3);
jobs.Enqueue("payment", 1);
jobs.Enqueue("report", 2);
while (jobs.TryDequeue(out var job, out var priority))
Console.WriteLine($"{priority}: {job}"); // payment, report, email
Dequeue/Pop on an empty collectionWhat happens: queue.Dequeue() without checking.
Why it is wrong: throws InvalidOperationException, which can crash a background service.
Do instead: use TryDequeue/TryPop, or check Count in single-threaded code.
Queue<T> across threadsWhat happens: a static queue shared between API requests and a background worker.
Why it is wrong: not thread-safe — lost items, duplicates, corrupted state and race conditions between Count and Dequeue.
Do instead: use Channel<T> or ConcurrentQueue<T>.
List<T>.RemoveAt(0) as a queueWhat happens: a list treated as FIFO.
Why it is wrong: each removal shifts all elements — O(n) per dequeue.
Do instead: use Queue<T>.
PriorityQueue to be FIFO for equal prioritiesWhat happens: jobs with the same priority are processed out of arrival order. Why it is wrong: the heap is not stable. Do instead: include a monotonically increasing sequence number in the priority.
PriorityQueueWhat happens: code searches the queue to change an element's priority. Why it is wrong: there's no update operation; enumeration is unordered and modification isn't supported. Do instead: enqueue the item again with the new priority and ignore stale entries when dequeued (lazy deletion), or use a different structure.
What happens: producers enqueue faster than consumers process. Why it is wrong: memory grows until the process is OOM-killed, and all pending work is lost on restart. Do instead: use bounded channels for backpressure, and durable queues (Service Bus) for important work.
What happens: var copy = new Stack<T>(original);.
Why it is wrong: the copy is reversed because the constructor pushes in enumeration (pop) order.
Do instead: new Stack<T>(original.Reverse()) or copy via ToArray() and reverse.
What happens: recursive DFS over large or user-supplied graphs.
Why it is wrong: deep inputs cause StackOverflowException, which cannot be caught.
Do instead: use an explicit Stack<T> and a visited set.
Contains on queues frequentlyWhat happens: checking for duplicates with queue.Contains(item) before enqueueing.
Why it is wrong: O(n) per check.
Do instead: maintain a companion HashSet<T> of queued items.
foreachWhat happens: dequeuing inside a foreach over the same queue.
Why it is wrong: throws InvalidOperationException due to version checks.
Do instead: use while (queue.TryDequeue(out var item)).
Seniors use these types to remove recursion and to make processing order explicit, and they know the limits.
| Problem | Tool | Why |
|---|---|---|
| Deep recursion risk | Stack<T> loop |
Heap memory, no StackOverflowException |
| Level-order processing | Queue<T> |
BFS order |
| Scheduling by urgency | PriorityQueue |
O(log n) dequeue of minimum |
| Ties must be FIFO | priority (level, seq) |
Heap is unstable |
| Cross-thread hand-off | Channel<T> |
Async, bounded, back-pressure |
They also mention that StackOverflowException cannot be caught in .NET and kills the process, which is a strong reason to make deep traversals iterative.
Push, Pop, Peek, all O(1). Used for the call stack, undo, expression parsing, DFS and backtracking.Enqueue, Dequeue, Peek, all O(1). Used for work queues, BFS, buffering and rate limiting.In .NET, Stack<T> is an array that grows at the end. Queue<T> is a circular buffer: head and tail indexes wrap around the array, so dequeuing does not shift elements.
Neither is thread-safe. For concurrency use ConcurrentQueue<T>/ConcurrentStack<T>, or better for producer/consumer pipelines, a bounded Channel<T> which adds async waiting and back-pressure.
A heap is a complete tree stored in an array that keeps the heap property: in a min-heap every parent ≤ its children, so the smallest item is always at the root.
.NET's PriorityQueue<TElement,TPriority> (.NET 6+) is an array-backed min-heap (4-ary internally). Enqueue, Dequeue, TryPeek, EnqueueDequeue; not stable for equal priorities; no decrease-key (Remove was added in .NET 9). For a max-heap, pass a reversed comparer.
Top K from a stream: keep a min-heap of size K; for each item, if it beats the heap's minimum, replace it. That is O(n log K) time and O(K) memory.
(Not to be confused with the managed heap, which is memory.)
System.Collections.Concurrent types are safe for many threads at once. ConcurrentDictionary uses lock-free reads and fine-grained (striped) locks for writes. Each single method is atomic, but check-then-act across two calls is not, so use GetOrAdd, AddOrUpdate, TryUpdate or TryRemove. Caveat: the value factory in GetOrAdd/AddOrUpdate can run more than once under contention (only one result is stored), so wrap expensive or side-effecting work in Lazy<T>. Count, IsEmpty and ToArray take every lock, so avoid them in hot paths. ConcurrentQueue/ConcurrentStack are lock-free FIFO/LIFO. ConcurrentBag is unordered, optimised for the same thread adding and taking. BlockingCollection adds bounding and blocking Take but blocks threads; for async producer/consumer use Channel<T>.
Last updated 30 Oct 2026.