// STUHUB · C++ DATA STRUCTURES
Circular Queue Wraparound and Linked-List Queue Pointers in C++
A focused C++ guide to queues: why a plain array queue reports false overflow, how modulo wraparound fixes it, how to tell full from empty (sacrificed slot vs. explicit count), and how the linked queue's front/rear pointer pair breaks on the empty-queue and last-node cases. Includes compilable implementations, per-operation complexity with reasons, a catalogue of real bugs, and three worked exercises.
Introduction
A queue is the data structure students skim past on the way to trees and graphs, and it is the one that quietly produces the most wrong answers. Not because the idea is hard — first in, first out, everyone gets that in ten seconds — but because both standard implementations hide a small piece of state that has to be maintained in two places at once, and forgetting one of them produces code that works on your test case and breaks on the tenth operation.
The array version's trap is arithmetic: indices that only move forward eventually run off the end of the buffer while most of the buffer sits empty. The linked version's trap is aliasing: two pointers describe one list, and the transitions in and out of the empty state are the only moments where they must be updated together.
This page works through both. Every code sample here compiles cleanly under g++ -std=c++17 -Wall -Wextra and the linked-list ones run clean under AddressSanitizer.
FIFO, and when a queue is the right shape
A queue exposes a deliberately narrow interface: you add at one end (enqueue) and remove from the other (front/dequeue). Nothing else. No indexing, no insert-in-the-middle, no search. That restriction is the whole point — it buys you O(1) on both operations and a guarantee that items come out in the order they went in.
Reach for a queue when arrival order is the correct service order:
- Breadth-first search. BFS is a queue. The frontier of a graph traversal is a FIFO, and swapping it for a stack silently turns your BFS into a DFS — the code still runs, the shortest-path guarantee just evaporates.
- Producer/consumer buffers. Keystrokes, network packets, print jobs, log lines. Something generates work faster or slower than something else consumes it, and reordering would be a bug.
- Level-order tree traversal, sliding-window scanning, round-robin scheduling.
Do not reach for a queue when you need priorities (that is a heap), or when the most recently added item should be handled first (that is a stack — undo, recursion, backtracking).
In production C++ you would write std::queue<T>, which wraps std::deque by default. The point of implementing one by hand is that the two failure modes below are the same two failure modes that show up in ring buffers, circular DMA descriptors, lock-free SPSC queues, and every intrusive linked list you will ever touch.
The naive array queue, and false overflow
The obvious first attempt: an array, an index front pointing at the first live item, an index rear pointing one past the last. Enqueue writes at rear and increments it; dequeue reads at front and increments it.
It works, right up until it doesn't. Run the code below: five enqueues fill the array, four dequeues empty most of it, and then the sixth enqueue is rejected while four of the five cells are free.
This is false overflow. Both indices only ever move right, so the live window slides toward the end of the array and the space behind front is abandoned. After k total enqueues the queue is permanently dead, no matter how many dequeues happened in between.
Two bad fixes, worth naming so you can reject them:
- Shift everything left on dequeue so
frontis always 0. Correct, but nowdequeuecopies up tonelements — O(n) per removal, O(n²) to drain the queue. You have destroyed the one property that made a queue worth using. - Grow the array when
rearhits the end. You are now allocating memory in proportion to the total traffic through the queue rather than its peak occupancy. A queue that holds three items but processes a million will allocate a million slots.
The real fix costs one operator.
// The naive version -- correct FIFO order, wrong capacity behaviour.
#include <iostream>
const int CAP = 5;
struct NaiveQueue {
int data[CAP];
int front = 0; // index of the first live item
int rear = 0; // index one past the last live item
bool enqueue(int v) {
if (rear == CAP) return false; // "overflow" -- but is it really?
data[rear++] = v;
return true;
}
bool dequeue(int& out) {
if (front == rear) return false;
out = data[front++];
return true;
}
};
int main() {
NaiveQueue q;
int x;
for (int v : {1, 2, 3, 4, 5}) q.enqueue(v);
for (int i = 0; i < 4; ++i) q.dequeue(x);
std::cout << "items held: " << (q.rear - q.front) << "\n"; // 1
std::cout << "enqueue succeeds? " << q.enqueue(6) << "\n"; // 0 <-- false overflow
}Modulo wraparound: the array bends into a ring
Stop treating the array as a line and start treating it as a circle. Index capacity - 1 is followed by index 0. Every index advance goes through the modulo operator:
front = (front + 1) % capacity;
rear = (rear + 1) % capacity;That is the entire fix. % capacity maps the successor of the last cell back to the first, so the live window is now free to rotate around the buffer forever. The cells behind front are no longer abandoned — they are simply the cells the window has not come back around to yet. Physical storage stops being tied to logical position, and the only thing that limits you is how many items are live at once, which is exactly the right limit.
Three properties worth internalising:
- The live region can be split in two. With
capacity = 5,front = 3, and four items, the data occupies indices 3, 4, 0, 1. Any code that assumes contiguity —memcpy(buf + front, ...),rear - front, afor (i = front; i < rear; ++i)loop — is wrong the moment the window wraps. If you need contiguous output, copy in two runs. - The indices never need resetting. Draining the queue does not send
frontandrearback to 0, and it shouldn't. Emptiness is a relationship between them, not a particular value of either. - Operator precedence will get you.
%binds tighter than+, sorear + 1 % capacityparses asrear + (1 % capacity)— that is justrear + 1, the bug you were trying to fix, compiling without a warning. Parenthesise:(rear + 1) % capacity.
One performance note for later: if you force capacity to be a power of two, % capacity becomes & (capacity - 1), replacing a division with a single AND. Real ring buffers do this. Do not attempt it unless the capacity is genuinely a power of two — the mask silently produces garbage otherwise.
Full or empty? Breaking the tie
Wraparound creates one new problem. When the queue is empty, front == rear. When the queue is completely full, the window has come all the way around and... front == rear again. Two opposite states, one representation. You have capacity + 1 distinct occupancy levels to encode and only capacity distinct values of (rear - front) mod capacity to encode them in — a counting argument, so some extra bit of information is unavoidable. Three standard ways to supply it:
1. Sacrifice a slot. Never let the queue hold more than capacity - 1 items. Then front == rear unambiguously means empty, and full is the state one step before the collision:
bool empty() const { return front == rear; }
bool full() const { return (rear + 1) % capacity == front; }
std::size_t size() const { return (rear + capacity - front) % capacity; }Costs you one cell and makes size() a computation. Buys you a queue whose entire state is two indices, with no third field that can drift out of sync. If a caller asks for room for k items, allocate k + 1 cells — getting this backwards is the classic off-by-one of this design.
2. Keep an explicit count. Store count alongside the indices. empty() is count == 0, full() is count == capacity, size() is free, and you get to use every cell. The cost is a third piece of state that both enqueue and dequeue must update correctly — one more place to be wrong, and a bug there is invisible until the queue is under load. A neat trick removes half the risk: store only front and count, and derive the rear as (front + count) % capacity. Now there is no second index to fall out of step with the counter.
3. Monotonic counters. Let head and tail be unsigned counters that only ever increase; index with head % capacity, and read the size as tail - head. Empty is head == tail, full is tail - head == capacity, all capacity cells are usable, and unsigned overflow is well-defined so the counters can wrap after 2⁶⁴ operations without incident. This is what high-performance and lock-free ring buffers use, because a single-writer/single-reader pair can update one counter each with no shared mutable index.
Which to pick. Sacrificed slot when the state must stay minimal or the memory is a fixed hardware buffer; explicit count when you want the full capacity and callers ask for size() often; monotonic counters when you are chasing throughput. What you must not do is mix conventions — pick one, and write the invariant in a comment at the top of the class, because six months later the code will not tell you whether rear means "last item" or "one past last".
A complete circular queue in C++
This is the front-plus-count variant: two stored fields, all cells usable, no separate rear index to desynchronise. Note that enqueue derives the write position rather than storing it — (front_ + count_) % cap_ is the slot one past the last live item, which is exactly where the new element belongs.
Read the wraparound in dequeue carefully. It does not clear the vacated cell, and it does not need to: the old value is simply outside the live window now, and it will be overwritten the next time the window rotates past it. (For a std::vector<T> of non-trivial types you would want to destroy the object; for int, leaving it is free and harmless.)
The program at the bottom exercises the wrap explicitly: it fills three of four cells, drains two, then enqueues three more so that the write index rolls over the end of the buffer. Output is 12 size=4 front=3, then 3456 — FIFO order preserved straight through the wraparound.
#include <cstddef>
#include <stdexcept>
#include <vector>
#include <iostream>
// Invariant: the live items occupy the cap_-modular range
// [front_, front_ + count_) with 0 <= count_ <= cap_.
class CircularQueue {
public:
explicit CircularQueue(std::size_t capacity)
: buf_(capacity), cap_(capacity), front_(0), count_(0) {
if (capacity == 0) throw std::invalid_argument("capacity must be > 0");
}
bool empty() const { return count_ == 0; }
bool full() const { return count_ == cap_; }
std::size_t size() const { return count_; }
void enqueue(int value) {
if (full()) throw std::overflow_error("queue is full");
std::size_t rear = (front_ + count_) % cap_; // derived, never stored
buf_[rear] = value;
++count_;
}
int dequeue() {
if (empty()) throw std::underflow_error("queue is empty");
int value = buf_[front_];
front_ = (front_ + 1) % cap_; // the wraparound
--count_;
return value;
}
int peek() const {
if (empty()) throw std::underflow_error("queue is empty");
return buf_[front_];
}
private:
std::vector<int> buf_;
std::size_t cap_;
std::size_t front_;
std::size_t count_;
};
int main() {
CircularQueue q(4);
q.enqueue(1); q.enqueue(2); q.enqueue(3);
std::cout << q.dequeue() << q.dequeue(); // 12
q.enqueue(4); q.enqueue(5); q.enqueue(6); // write index wraps here
std::cout << " size=" << q.size()
<< " front=" << q.peek() << "\n"; // size=4 front=3
while (!q.empty()) std::cout << q.dequeue(); // 3456
std::cout << "\n";
try { q.dequeue(); }
catch (const std::underflow_error& e) { std::cout << e.what() << "\n"; }
}The linked queue: two pointers describing one list
When you do not know the peak occupancy in advance, back the queue with a singly linked list. Keep front_ at the head (so removal is O(1)) and rear_ at the tail (so insertion is O(1)). The direction of the next pointers matters: they must run front → rear, so that removing the head is a one-hop update. If you linked them the other way, dequeue would have to find the head's successor by traversal.
The two lines that carry all the risk are marked in the code:
enqueueon an empty queue. There is no old tail to link from, sorear_->next = nodewould dereference null. Both pointers must be set to the new node: it is simultaneously the first and the last element. The check isrear_ == nullptr(equivalentlyfront_ == nullptr— the invariant is that they are null together or non-null together).dequeueof the last node. Afterfront_ = old->next,front_is null. If you stop there,rear_still points at the node you are about todelete— a dangling pointer. The queue now reports empty viafront_, but the nextenqueuetakes the non-empty branch, writesrear_->nextinto freed memory, and you have heap corruption that surfaces somewhere else entirely. One line prevents it:if (front_ == nullptr) rear_ = nullptr;
Also note the ordering inside the non-empty enqueue branch. rear_->next = node; must come before rear_ = node;. Reversed, you overwrite the only pointer to the old tail, then set the new node's next to itself — a self-loop, an infinite dequeue, and a leaked list.
And note what this class does that a classroom sketch usually skips: it has a destructor, a copy constructor, and an assignment operator. The compiler-generated copy would duplicate the two raw pointers, giving you two queues sharing one node chain and two destructors racing to free it. Owning raw pointers means you own the rule of three.
#include <iostream>
#include <stdexcept>
#include <utility>
// Invariant: front_ and rear_ are both null (empty) or both non-null.
// rear_->next is always nullptr.
class LinkedQueue {
struct Node {
int data;
Node* next;
explicit Node(int d) : data(d), next(nullptr) {}
};
Node* front_ = nullptr;
Node* rear_ = nullptr;
std::size_t count_ = 0;
public:
LinkedQueue() = default;
~LinkedQueue() { clear(); }
LinkedQueue(const LinkedQueue& other) {
for (Node* p = other.front_; p != nullptr; p = p->next) enqueue(p->data);
}
LinkedQueue& operator=(LinkedQueue other) { swap(other); return *this; }
LinkedQueue(LinkedQueue&& other) noexcept { swap(other); }
void swap(LinkedQueue& other) noexcept {
std::swap(front_, other.front_);
std::swap(rear_, other.rear_);
std::swap(count_, other.count_);
}
bool empty() const { return front_ == nullptr; }
std::size_t size() const { return count_; }
void enqueue(int value) {
Node* node = new Node(value);
if (rear_ == nullptr) {
front_ = rear_ = node; // (1) empty queue: node is BOTH ends
} else {
rear_->next = node; // link the old tail first...
rear_ = node; // ...then move the tail marker
}
++count_;
}
int dequeue() {
if (empty()) throw std::underflow_error("dequeue on empty queue");
Node* old = front_;
int value = old->data; // read BEFORE delete
front_ = old->next;
if (front_ == nullptr) rear_ = nullptr; // (2) removed the last node
delete old;
--count_;
return value;
}
int& peek() {
if (empty()) throw std::underflow_error("peek on empty queue");
return front_->data;
}
void clear() { while (!empty()) dequeue(); }
};
int main() {
LinkedQueue q;
q.enqueue(1); q.enqueue(2);
std::cout << q.dequeue() << q.dequeue()
<< " empty=" << q.empty() << "\n"; // 12 empty=1
q.enqueue(7); // re-enters via branch (1)
std::cout << "front=" << q.peek()
<< " size=" << q.size() << "\n"; // front=7 size=1
}Complexity, and why each bound holds
| Operation | Circular array | Linked queue | Why |
|---|---|---|---|
enqueue | O(1) | O(1) | Array: one modulo, one store, one increment — a fixed instruction count regardless of n. Linked: one allocation plus two pointer writes; rear_ means no traversal to find the tail. |
dequeue | O(1) | O(1) | Array: one read, one modulo, one decrement. Linked: one pointer hop and one delete. Neither touches more than a constant number of nodes. |
peek / front | O(1) | O(1) | Direct index or direct pointer. |
empty / size | O(1) | O(1) | Field comparison, or a single modulo for the counter-free variant. |
| Space | O(capacity) | O(n) | The array pays for its peak up front, including cells never used; the list pays only for live nodes, plus one pointer of overhead per node. |
Why the array version is O(1) and the naive fix is not. Both front and rear move forward only, one step per operation. No element is ever relocated after it is written. The alternative — compacting the array so front stays at 0 — moves every surviving element on every removal, which is O(n) per dequeue and O(n²) to drain. Wraparound is what lets you keep the elements still while the window moves.
Why the modulo does not spoil the bound. % is a single instruction on a fixed-width integer; it is constant time even though it is slower than an AND. With a power-of-two capacity the compiler reduces it to a mask.
Constant factors, which matter more than the table suggests. The array queue keeps its data in one contiguous block, so consecutive reads are cache hits and the hardware prefetcher works. The linked queue calls the allocator on every enqueue and delete on every dequeue, and its nodes scatter across the heap, so every dequeue risks a cache miss on front_->next. Same O(1), routinely several times slower in practice. Prefer the array whenever you can bound the occupancy; use the list when you truly cannot, or when a hard capacity limit would be a correctness bug.
A note on resizable ring buffers. If you want unbounded capacity and contiguity, grow the array by doubling: enqueue becomes amortised O(1) (each element is copied O(log n) times across n insertions, totalling O(n) work). The subtlety is that you cannot memcpy the old buffer into the new one when the window is wrapped — you must un-wrap it, copying the run from front to the end first and the run from 0 to rear second, and then reset front to 0.
The mistakes people actually make
Circular array
rear + 1 % capacity. Precedence.%binds tighter than+, so this isrear + 1and your queue overruns the buffer. Always(rear + 1) % capacity.(i - 1) % capacityto step backwards. With signed indices, C++%truncates toward zero, so-1 % 5is-1, not4— a negative index. Withstd::size_tthe subtraction wraps to an enormous value first. Write(i + capacity - 1) % capacity.rear - frontas the size. Wrong the instant the window wraps, and off-by-one-in-the-wrong-direction rather than crashing, so it survives testing. Use(rear + capacity - front) % capacity, or keep a counter.- Allocating
kcells for ak-item sacrificed-slot queue. You getk - 1. Allocatek + 1. - Not deciding what
rearmeans. "Index of the last item" and "index one past the last item" produce different full tests, different empty tests, and differentenqueuebodies. Both are fine; mixing them within one class is not. Write the convention down in a comment. - Iterating with
for (i = front; i < rear; ++i). Fails wheneverrear < front. Iterate by count instead:for (k = 0; k < size(); ++k) use(buf[(front + k) % capacity]); - Testing only the happy path. A circular queue that is never filled past the wrap point is just the naive queue. Your test must enqueue past
capacity, drain, and enqueue again.
Linked queue
- The dangling
rear_. Dequeue the last node without settingrear_ = nullptrand the tail pointer aims at freed memory. The queue looks fine —empty()readsfront_and says yes — and blows up on the nextenqueue. This is the single most common queue bug there is, and the reason to run your tests under-fsanitize=address. - Forgetting
front_too, in the other direction. Enqueueing into an empty queue must set both pointers. Setting onlyrear_leavesfront_null, so the queue permanently reports empty and the node leaks. - Assignment order in
enqueue.rear_ = node; rear_->next = node;builds a self-loop and orphans the rest of the list. Link before you advance. deletebefore you read.delete old; return old->data;is use-after-free. It will often print the right number, which is the worst possible outcome.- Moving
front_without deleting. A leak per dequeue. Similarly, a missing destructor leaks the entire list —clear()must actually run. - Letting the compiler copy your queue. Default copy duplicates raw pointers: two objects, one node chain, double free at scope exit. Define, delete, or otherwise handle the rule of three.
- Keeping only a
frontpointer to save a field. Enqueue then costs a full traversal, turning your O(1) queue into O(n) without any error message. If you want a one-pointer queue, keep the rear of a circular list — see exercise 3.
Exercises with solutions
Work through each question before opening the solution below it.
Exercise 1
Trace a circular queue. You have a circular queue built on an array of 5 cells, using the sacrificed-slot scheme (front == rear means empty, (rear + 1) % 5 == front means full, rear is one past the last item). Both indices start at 0. Execute this sequence and report front, rear, size(), and the buffer contents after each stage:
enqueue(A),enqueue(B),enqueue(C),enqueue(D)enqueue(E)dequeue(),dequeue()enqueue(E),enqueue(F)- Drain the queue with repeated
dequeue()
What is the maximum number of items this queue can ever hold, and at which stage does the physical wraparound actually happen?
Solution
Stage 1. Each enqueue writes at rear then advances it.
| after | buffer (index 0..4) | front | rear |
|---|---|---|---|
A | [A . . . .] | 0 | 1 |
B | [A B . . .] | 0 | 2 |
C | [A B C . .] | 0 | 3 |
D | [A B C D .] | 0 | 4 |
size() = (4 + 5 - 0) % 5 = 4. Full test: (4 + 1) % 5 == 0 == front → the queue is now full with one cell (index 4) deliberately empty.
Stage 2. enqueue(E) is rejected. This is a true overflow, not a false one: four items really are present and the fifth cell is the sacrifice that keeps the full/empty test unambiguous.
Stage 3. dequeue() returns A (front 0→1), dequeue() returns B (front 1→2). Buffer is unchanged as raw bytes — A and B are still physically sitting in cells 0 and 1, they are simply outside the live window. State: front = 2, rear = 4, size() = (4 + 5 - 2) % 5 = 2, live items C D.
Stage 4. enqueue(E) writes at index 4, then rear = (4 + 1) % 5 = 0 — this is the wraparound, and it is the moment the naive queue would have died. enqueue(F) writes at index 0 (overwriting the stale A), then rear = 1.
State: buffer [F B C D E], front = 2, rear = 1, size() = (1 + 5 - 2) % 5 = 4. Full test: (1 + 1) % 5 = 2 == front → full again. Note that rear < front now; that is normal and is exactly why you must never compute the size as rear - front.
Stage 5. Draining reads indices 2, 3, 4, 0 → C D E F — correct FIFO order — leaving front == rear == 1 and size() == 0. The indices do not return to 0, and they never need to: emptiness is a relationship between the two indices, not a specific value of either.
Maximum occupancy: 4. With n cells the sacrificed-slot scheme stores n - 1 items. If you need to store exactly k items, allocate k + 1 cells.
Exercise 2
Size without a counter. For the sacrificed-slot queue, implement size() without storing a count field. Then answer: why is the naive return rear - front; wrong, and why does the standard fix add the capacity before the modulo? Does the answer change if front and rear are declared int instead of std::size_t?
Solution
std::size_t size() const { return (rear_ + cap_ - front_) % cap_; }Why rear - front fails. It is correct only in the un-wrapped layout where front <= rear. Once the window straddles the end of the array — front = 2, rear = 1, 4 items live — the subtraction gives -1. The live region is really two runs (front..cap-1 and 0..rear-1) whose combined length is (cap - front) + rear, and that expression is precisely (rear + cap - front) % cap. In the un-wrapped case the same formula reduces to rear - front, because rear - front < cap makes the modulo a no-op. One expression, both cases.
Why add cap_ first. Two independent reasons, and only one of them is about overflow:
- With signed indices, C++
%truncates toward zero, so a negative dividend yields a negative or zero result:(1 - 2) % 5 == -1 % 5 == -1, not4. C++%is a remainder, not a mathematical modulo. Addingcapfirst guarantees a non-negative dividend, so the remainder is the residue you actually wanted. - With unsigned indices,
1 - 2does not go negative at all; it wraps to a huge value. Unsigned wraparound is well-defined, so(rear - front + cap) % caphappens to still produce the right answer, but you are relying on a subtlety no reviewer wants to re-derive. Writingrear_ + cap_ - front_keeps every intermediate value non-negative and obviously in range (front_, rear_ < cap_, so the sum is below2 * cap_and cannot overflow any sane type).
So: with int the + cap is mandatory for correctness; with std::size_t it is mandatory for readability, and the ordering rear + cap - front is what makes it self-evidently safe. Use the same guard whenever you step an index backwards: the predecessor of i is (i + cap - 1) % cap, never (i - 1) % cap.
Exercise 3
A linked queue with one pointer. A textbook linked queue keeps two pointers, front and rear. Show that you can get O(1) enqueue and O(1) dequeue from a circular singly linked list using a single pointer, and explain why that pointer must be the rear and not the front. Handle the empty queue and the remove-the-last-node case. Write and reason through the C++.
Solution
The idea. Make the last node's next point back at the first node. Then from the rear you can reach the front in one hop — rear->next is the front — so a single rear pointer gives you constant-time access to both ends.
Why rear and not front. From a lone front pointer, appending would require walking the whole list to find the last node: O(n). From a lone rear pointer, both ends are O(1): you write after rear, and you read at rear->next. In general, keep the pointer to the end you cannot reach cheaply from the other one.
class RearOnlyQueue {
struct Node { int data; Node* next; };
Node* rear_ = nullptr; // rear_->next is the front
public:
RearOnlyQueue() = default;
~RearOnlyQueue() { int x; while (dequeue(x)) {} }
RearOnlyQueue(const RearOnlyQueue&) = delete;
RearOnlyQueue& operator=(const RearOnlyQueue&) = delete;
bool empty() const { return rear_ == nullptr; }
void enqueue(int v) {
Node* n = new Node{v, nullptr};
if (rear_ == nullptr) {
n->next = n; // a one-node cycle points at itself
rear_ = n;
} else {
n->next = rear_->next; // new node takes over as predecessor of the front
rear_->next = n; // old rear now precedes the new node
rear_ = n; // and the new node becomes the rear
}
}
bool dequeue(int& out) {
if (rear_ == nullptr) return false;
Node* head = rear_->next; // the front
out = head->data;
if (head == rear_) rear_ = nullptr; // it was the only node: reset, do not dangle
else rear_->next = head->next; // splice the front out of the cycle
delete head;
return true;
}
};Reasoning through the three cases.
- Enqueue into empty: the self-loop
n->next = nis the base case that makes every later insertion uniform — after it,rear_->nextis a valid front even with one node. - Enqueue into non-empty: the three assignments must run in that order. Doing
rear_ = nfirst would lose the old rear and leave the cycle broken. - Dequeue the last node:
head == rear_detects it. Settingrear_ = nullptris the exact analogue of resettingrearin the two-pointer queue; skip it andrear_points at freed memory, and the nextenqueuewrites through a dangling pointer.
Complexity. Both operations touch a fixed number of nodes, so both are O(1); space is O(n) with one pointer of overhead per node.
Bonus observation. Because rear_->next is the front, rear_ = rear_->next; rotates the queue — moving the front element to the back — in O(1) with no allocation. That is the reason this layout shows up in round-robin schedulers, which cycle through clients forever rather than draining them.
Published and updated 2026-08-09 · DUOCODE TECHNOLOGY