← Back to DevBytes

Interview Guide: Circular Buffers Problems and Solutions

What is a Circular Buffer?

A circular buffer (also known as a ring buffer or cyclic buffer) is a fixed-size data structure that treats its underlying memory as if it were connected end-to-end. When data is added or removed, two pointers—often called head (or read pointer) and tail (or write pointer)—move around the buffer. When either pointer reaches the end of the physical storage, it wraps around to the beginning, forming a logical circle.

The fundamental invariant of a circular buffer is that elements are stored in a contiguous logical sequence, but the physical layout wraps modulo the buffer's capacity. This makes circular buffers ideal for streaming data scenarios where you want bounded memory usage and O(1) enqueue/dequeue operations.

Core Concepts

Why Circular Buffers Matter in Interviews

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Interviewers love circular buffer problems because they test multiple skills simultaneously:

Common Use Cases

Implementing a Circular Buffer

Fixed-Size Array Implementation (Non-Overwriting)

This is the canonical interview implementation. We sacrifice one slot to distinguish full from empty. The buffer stores elements in a fixed array, using head and tail indices.

#include <iostream>
#include <vector>
#include <optional>
#include <stdexcept>

template <typename T>
class CircularBuffer {
private:
    std::vector<T> buffer;
    size_t head;      // index of next element to read
    size_t tail;      // index of next empty slot
    size_t count;     // number of elements currently stored
    size_t capacity;

public:
    explicit CircularBuffer(size_t cap) 
        : buffer(cap), head(0), tail(0), count(0), capacity(cap) {
        if (cap == 0) {
            throw std::invalid_argument("Capacity must be positive");
        }
    }

    // Check if buffer is empty
    bool empty() const {
        return count == 0;
    }

    // Check if buffer is full
    bool full() const {
        return count == capacity;
    }

    // Number of elements currently stored
    size_t size() const {
        return count;
    }

    // Maximum elements the buffer can hold
    size_t get_capacity() const {
        return capacity;
    }

    // Push an element (blocks or fails if full)
    bool push(const T& item) {
        if (full()) {
            return false;  // Buffer full, reject
        }
        buffer[tail] = item;
        tail = (tail + 1) % capacity;
        count++;
        return true;
    }

    // Push with move semantics
    bool push(T&& item) {
        if (full()) {
            return false;
        }
        buffer[tail] = std::move(item);
        tail = (tail + 1) % capacity;
        count++;
        return true;
    }

    // Pop an element (returns nullopt if empty)
    std::optional<T> pop() {
        if (empty()) {
            return std::nullopt;
        }
        T value = std::move(buffer[head]);
        head = (head + 1) % capacity;
        count--;
        return value;
    }

    // Peek at the next element without removing
    std::optional<T> peek() const {
        if (empty()) {
            return std::nullopt;
        }
        return buffer[head];
    }

    // Clear all elements
    void clear() {
        head = 0;
        tail = 0;
        count = 0;
    }
};

Key design decisions in this implementation:

Linked-List Based Circular Buffer

While less common in interviews (due to dynamic allocation), a linked-list circular buffer has the advantage of no fixed capacity limit while still maintaining circular traversal behavior. This is useful for round-robin scheduling or circular iteration patterns.

template <typename T>
class CircularLinkedList {
private:
    struct Node {
        T data;
        Node* next;
        Node(const T& val) : data(val), next(nullptr) {}
    };

    Node* sentinel;   // Dummy node marking start/end
    size_t count;

public:
    CircularLinkedList() : sentinel(new Node(T{})), count(0) {
        sentinel->next = sentinel;  // Points to itself, forming a circle
    }

    ~CircularLinkedList() {
        // Delete all nodes except sentinel
        Node* current = sentinel->next;
        while (current != sentinel) {
            Node* temp = current;
            current = current->next;
            delete temp;
        }
        delete sentinel;
    }

    // Insert at the end (before sentinel)
    void push_back(const T& value) {
        Node* newNode = new Node(value);
        // Find the node whose next is sentinel (the "last" node)
        Node* last = sentinel;
        while (last->next != sentinel) {
            last = last->next;
        }
        newNode->next = sentinel;
        last->next = newNode;
        count++;
    }

    // Remove from the front (after sentinel)
    bool pop_front() {
        if (sentinel->next == sentinel) {
            return false;  // Empty
        }
        Node* first = sentinel->next;
        sentinel->next = first->next;
        delete first;
        count--;
        return true;
    }

    // Advance the sentinel, effectively rotating the list
    // The first element becomes the last
    void rotate() {
        if (sentinel->next != sentinel) {
            sentinel = sentinel->next;
        }
    }

    size_t size() const { return count; }
    bool empty() const { return count == 0; }
};

This linked-list version uses a sentinel node that points to itself when empty. The rotate() method demonstrates circular behavior: advancing the sentinel pointer shifts the logical start of the list without moving any data.

Common Interview Problems and Solutions

Problem 1: Basic Circular Buffer Operations

Prompt: "Implement a bounded queue using a circular array. Support enqueue, dequeue, peek, isEmpty, and isFull operations."

This is the foundational problem. Interviewers expect you to handle wrap-around correctly and clearly explain your full/empty detection strategy. The implementation shown above in Section "Fixed-Size Array Implementation" directly solves this. Be prepared to discuss:

Problem 2: Thread-Safe Producer-Consumer Circular Buffer

Prompt: "Design a thread-safe circular buffer where multiple producers can enqueue and multiple consumers can dequeue concurrently."

This escalates the basic problem to concurrent programming. You must prevent race conditions on head, tail, and count. The classic solution uses a mutex and two condition variables.

#include <mutex>
#include <condition_variable>
#include <vector>
#include <optional>

template <typename T>
class ThreadSafeCircularBuffer {
private:
    std::vector<T> buffer;
    size_t head;
    size_t tail;
    size_t count;
    size_t capacity;
    mutable std::mutex mtx;
    std::condition_variable not_full;
    std::condition_variable not_empty;
    bool shutdown_requested;

public:
    explicit ThreadSafeCircularBuffer(size_t cap)
        : buffer(cap), head(0), tail(0), count(0), capacity(cap), 
          shutdown_requested(false) {
        if (cap == 0) throw std::invalid_argument("Capacity must be > 0");
    }

    // Signal all waiting threads to wake up (for graceful shutdown)
    void shutdown() {
        std::lock_guard<std::mutex> lock(mtx);
        shutdown_requested = true;
        not_full.notify_all();
        not_empty.notify_all();
    }

    // Blocking push — waits until space is available or shutdown
    bool push(const T& item) {
        std::unique_lock<std::mutex> lock(mtx);
        not_full.wait(lock, [this]() {
            return count < capacity || shutdown_requested;
        });
        
        if (shutdown_requested) return false;
        
        buffer[tail] = item;
        tail = (tail + 1) % capacity;
        count++;
        
        // Notify one waiting consumer
        not_empty.notify_one();
        return true;
    }

    // Blocking pop — waits until data is available or shutdown
    std::optional<T> pop() {
        std::unique_lock<std::mutex> lock(mtx);
        not_empty.wait(lock, [this]() {
            return count > 0 || shutdown_requested;
        });
        
        if (shutdown_requested && count == 0) {
            return std::nullopt;
        }
        
        T value = std::move(buffer[head]);
        head = (head + 1) % capacity;
        count--;
        
        // Notify one waiting producer
        not_full.notify_one();
        return value;
    }

    // Non-blocking try-push
    bool try_push(const T& item) {
        std::lock_guard<std::mutex> lock(mtx);
        if (count >= capacity) return false;
        
        buffer[tail] = item;
        tail = (tail + 1) % capacity;
        count++;
        not_empty.notify_one();
        return true;
    }

    // Non-blocking try-pop
    std::optional<T> try_pop() {
        std::lock_guard<std::mutex> lock(mtx);
        if (count == 0) return std::nullopt;
        
        T value = std::move(buffer[head]);
        head = (head + 1) % capacity;
        count--;
        not_full.notify_one();
        return value;
    }

    size_t size() const {
        std::lock_guard<std::mutex> lock(mtx);
        return count;
    }
};

Critical design points to mention in an interview:

Problem 3: Ring Buffer with Overwriting (Lossy Mode)

Prompt: "Implement a circular buffer for a logging system. When full, new entries overwrite the oldest entries silently."

In this variant, the buffer never blocks—it always accepts writes. The read pointer must stay ahead of (or equal to) the write pointer, and the buffer is conceptually always full after initial filling.

template <typename T>
class OverwritingCircularBuffer {
private:
    std::vector<T> buffer;
    size_t head;      // oldest valid element
    size_t tail;      // next write position
    size_t capacity;
    bool is_full;     // true once buffer has filled at least once

public:
    explicit OverwritingCircularBuffer(size_t cap)
        : buffer(cap), head(0), tail(0), capacity(cap), is_full(false) {
        if (cap == 0) throw std::invalid_argument("Capacity must be > 0");
    }

    // Always succeeds — overwrites oldest if full
    void push(const T& item) {
        buffer[tail] = item;
        tail = (tail + 1) % capacity;
        
        if (is_full) {
            // Overwrote the oldest element, advance head
            head = (head + 1) % capacity;
        } else if (tail == head) {
            // Buffer just became full
            is_full = true;
        }
    }

    // Returns nullopt if no data has been written yet
    std::optional<T> pop() {
        if (!is_full && head == tail) {
            return std::nullopt;  // Truly empty
        }
        
        T value = std::move(buffer[head]);
        head = (head + 1) % capacity;
        is_full = false;  // We freed a slot, so not full anymore
        return value;
    }

    // Read all valid elements in order (oldest to newest)
    std::vector<T> snapshot() const {
        std::vector<T> result;
        if (is_full) {
            // Read from head to end, then wrap to head-1
            for (size_t i = head; i != tail; i = (i + 1) % capacity) {
                result.push_back(buffer[i]);
            }
        } else {
            // Buffer hasn't wrapped yet
            for (size_t i = head; i != tail; i = (i + 1) % capacity) {
                result.push_back(buffer[i]);
            }
        }
        return result;
    }

    size_t size() const {
        if (is_full) return capacity;
        if (tail >= head) return tail - head;
        return tail + capacity - head;  // Wrapped case
    }
};

This implementation tracks is_full as a boolean flag. When the buffer wraps around for the first time, it sets is_full = true. From that point on, every push advances both tail and head, maintaining exactly capacity elements. This is perfect for fixed-size sliding windows or log tailing.

Problem 4: Circular Buffer for Sliding Window Average

Prompt: "Using a circular buffer, compute the moving average of the last N data points in a streaming data scenario."

This combines the overwriting buffer with a running computation. Instead of storing and summing all values on each read, you can maintain a running sum for O(1) average computation.

class MovingAverage {
private:
    std::vector<double> buffer;
    size_t head;
    size_t tail;
    size_t capacity;
    size_t count;
    double running_sum;

public:
    explicit MovingAverage(size_t window_size)
        : buffer(window_size, 0.0), head(0), tail(0), 
          capacity(window_size), count(0), running_sum(0.0) {
    }

    // Add a new value and get the updated average
    double next(double value) {
        if (count == capacity) {
            // Buffer is full — subtract oldest value before overwriting
            running_sum -= buffer[head];
            head = (head + 1) % capacity;
            count--;
        }
        
        // Add new value
        buffer[tail] = value;
        running_sum += value;
        tail = (tail + 1) % capacity;
        count++;
        
        return running_sum / static_cast<double>(count);
    }

    // Get current average without adding new data
    double current_average() const {
        if (count == 0) return 0.0;
        return running_sum / static_cast<double>(count);
    }

    // Reset the window
    void reset() {
        head = 0;
        tail = 0;
        count = 0;
        running_sum = 0.0;
        std::fill(buffer.begin(), buffer.end(), 0.0);
    }
};

Each call to next() is O(1) regardless of window size. The buffer stores raw values, while running_sum avoids recomputing the sum from scratch. This pattern appears in real-time signal processing, financial tick data analysis, and sensor fusion algorithms.

Best Practices for Circular Buffer Interviews

Conclusion

Circular buffers are deceptively simple structures that reveal a candidate's mastery of fundamental programming concepts: pointer arithmetic, modulo operations, boundary condition handling, and concurrency primitives. The difference between a shaky implementation and a confident one often comes down to how thoroughly edge cases are handled—empty buffers, full buffers, wrap-around transitions, and thread wakeups during shutdown.

By working through the four problem archetypes presented here—basic bounded queue, thread-safe producer-consumer, lossy overwriting buffer, and sliding window computation—you'll be prepared for virtually any circular buffer question an interviewer can throw at you. Focus on clarity of thought, explicit state management, and robust error handling, and you'll turn this interview staple into a showcase of your engineering maturity.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles