Skip to content

@studnicky/circular-buffer

Generic ring buffer with O(1) push and shift operations.

Install

bash
pnpm add @studnicky/circular-buffer

Usage

Fixed-capacity ring buffer. When the buffer is full, the oldest item is evicted and the new item takes its slot. Length stays at capacity:

ts
import { CircularBuffer } from '../src/index.js';

// Fixed-capacity ring: capacity 3, overflow defaults to 'overwrite'
const buf = CircularBuffer.create<number>({ 'capacity': 3 });

buf.push(1);
buf.push(2);
buf.push(3);
buf.push(4); // buffer is full — 1 is evicted, ring holds [2, 3, 4]

console.log(`length after 4 pushes into capacity-3 ring: ${buf.length}`);
console.log(`shift: ${buf.shift()}`); // 2 — oldest surviving item
console.log(`shift: ${buf.shift()}`); // 3
console.log(`shift: ${buf.shift()}`); // 4
console.log(`length after drain: ${buf.length}`);

Try it

Builder

CircularBuffer.builder().withCapacity(n).withOverflow('overwrite' | 'grow').build() constructs the ring through the fluent builder. Press Execute to see both overflow strategies: an overwrite-mode capacity-3 ring evicts the oldest item on overflow (drains to [2, 3, 4]), while a grow-mode capacity-2 ring doubles to capacity 4 instead of dropping anything.

Builder — fluent ring buffer construction
/** builder-circular-buffer — construct a CircularBuffer via the fluent builder API. Run: npx tsx packages/circular-buffer/examples/builder-circular-buffer.ts */
import assert from 'node:assert/strict';

import { CircularBuffer } from '../src/index.js';

// #region usage
class BuilderCircularBufferExample {
  static run(): void {
    // Build a capacity-3 ring in overwrite mode.
    // On overflow, the oldest item is evicted.
    const overwriteRing = CircularBuffer.builder<number>()
      .withCapacity(3)
      .withOverflow('overwrite')
      .build();

    console.log('overwrite ring — initial length:', overwriteRing.length);

    overwriteRing.push(1);
    overwriteRing.push(2);
    overwriteRing.push(3);
    console.log('overwrite ring — after push(1,2,3), length:', overwriteRing.length);

    overwriteRing.push(4); // evicts 1
    console.log('overwrite ring — after push(4) (evicts 1), length:', overwriteRing.length);

    const overwriteDrained: number[] = [];
    let overwriteItem = overwriteRing.shift();
    while (overwriteItem !== undefined) {
      overwriteDrained.push(overwriteItem);
      overwriteItem = overwriteRing.shift();
    }
    console.log('overwrite ring — drained:', overwriteDrained);

    // Build a capacity-2 ring in grow mode.
    // On overflow, capacity doubles instead of evicting items.
    const growRing = CircularBuffer.builder<number>()
      .withCapacity(2)
      .withOverflow('grow')
      .build();

    console.log('grow ring — initial length:', growRing.length);

    growRing.push(10);
    growRing.push(20);
    growRing.push(30); // triggers grow: capacity doubles to 4, nothing evicted
    console.log('grow ring — after push(10,20,30), length:', growRing.length);

    const growDrained: number[] = [];
    let growItem = growRing.shift();
    while (growItem !== undefined) {
      growDrained.push(growItem);
      growItem = growRing.shift();
    }
    console.log('grow ring — drained:', growDrained);

    assert.deepEqual(overwriteDrained, [2, 3, 4]);
    assert.equal(growDrained.length, 3);
    assert.deepEqual(growDrained, [10, 20, 30]);

    console.log('builder-circular-buffer: all assertions passed');
  }
}

BuilderCircularBufferExample.run();
// #endregion usage
Output
Press Execute to run this example against the real library.

Lifecycle hooks

TracingBuffer subclasses CircularBuffer and overrides five hooks: onOverflow, onEvict, onPush, onShift, and onGrow. Two scenarios run: an overwrite-mode ring (capacity 3, 5 pushes — watch 2 overflow and 2 eviction events) and a grow-mode ring (capacity 2, 3 pushes — watch the buffer double to capacity 4 instead of evicting).

Observed ring buffer — lifecycle hook trace
/** observedCircularBuffer — trace all lifecycle hooks via console.log. Run: npx tsx examples/observedCircularBuffer.ts */

import assert from 'node:assert/strict';

// #region usage
import { CircularBuffer } from '../src/index.js';

class TracingBuffer<T> extends CircularBuffer<T> {
  readonly overflowItems: T[] = [];
  readonly evictItems: T[] = [];
  readonly growEvents: { 'newCapacity': number; 'oldCapacity': number }[] = [];
  readonly pushItems: T[] = [];
  readonly shiftItems: T[] = [];

  protected override onOverflow(item: T): void {
    console.log(`[circular-buffer] overflow incoming=${String(item)}`);
    this.overflowItems.push(item);
  }

  protected override onEvict(item: T): void {
    console.log(`[circular-buffer] evict item=${String(item)}`);
    this.evictItems.push(item);
  }

  protected override onGrow(oldCapacity: number, newCapacity: number): void {
    console.log(`[circular-buffer] grow ${oldCapacity} → ${newCapacity}`);
    this.growEvents.push({ 'newCapacity': newCapacity, 'oldCapacity': oldCapacity });
  }

  protected override onPush(item: T): void {
    console.log(`[circular-buffer] push item=${String(item)} length=${this.length}`);
    this.pushItems.push(item);
  }

  protected override onShift(item: T): void {
    console.log(`[circular-buffer] shift item=${String(item)} length=${this.length}`);
    this.shiftItems.push(item);
  }
}

// Scenario 1: capacity-3 overwrite buffer, push 5 items (2 overflows/evictions)
console.log('--- overwrite mode (capacity 3) ---');
const ring = TracingBuffer.create<number>({ 'capacity': 3 });
ring.push(1);
ring.push(2);
ring.push(3);
ring.push(4); // overflow: evicts 1
ring.push(5); // overflow: evicts 2

console.log('shifting all remaining items:');
while (ring.length > 0) { ring.shift(); }

// Scenario 2: capacity-2 grow-mode buffer
console.log('--- grow mode (capacity 2) ---');
const growing = TracingBuffer.create<number>({ 'capacity': 2, 'overflow': 'grow' });
growing.push(10);
growing.push(20);
growing.push(30); // triggers grow 2 → 4

console.log('observedCircularBuffer: scenarios complete');
// #endregion usage

// Assertions (outside the #region — these verify hook counts, not demo output)

// overwrite ring: 5 pushes total, 2 overflows, 2 evictions, 3 shifts
assert.strictEqual(ring.pushItems.length, 5);
assert.strictEqual(ring.overflowItems.length, 2);
assert.deepStrictEqual(ring.overflowItems, [4, 5]);
assert.strictEqual(ring.evictItems.length, 2);
assert.deepStrictEqual(ring.evictItems, [1, 2]);
assert.strictEqual(ring.shiftItems.length, 3);
assert.strictEqual(ring.growEvents.length, 0);

// grow buffer: 3 pushes, 1 grow event, no overflow, no evictions
assert.strictEqual(growing.pushItems.length, 3);
assert.strictEqual(growing.growEvents.length, 1);
assert.strictEqual(growing.growEvents[0]?.oldCapacity, 2);
assert.strictEqual(growing.growEvents[0]?.newCapacity, 4);
assert.strictEqual(growing.overflowItems.length, 0);
assert.strictEqual(growing.evictItems.length, 0);

console.log('observedCircularBuffer: all assertions passed');
Output
Press Execute to run this example against the real library.

Subpath exports

SubpathContents
@studnicky/circular-bufferCircularBuffer, CircularBufferBuilder, CircularBufferOptionsEntity
@studnicky/circular-buffer/circular-bufferCircularBuffer, CircularBufferBuilder (direct subpath)
@studnicky/circular-buffer/interfacesCircularBufferInterface
@studnicky/circular-buffer/constantsBuffer configuration constants

Extending

CircularBuffer is a class; subclass it to add domain-specific behavior. Override the protected hooks onEvict, onGrow, onPush, and onShift to observe lifecycle events without coupling business logic to the buffer internals:

ts
import { CircularBuffer } from '../src/index.js';

// Default overwrite ring — observe evictions
class EvictTracker<T> extends CircularBuffer<T> {
  readonly evictedItems: T[] = [];

  protected override onEvict(item: T): void {
    this.evictedItems.push(item);
  }
}

const ring = EvictTracker.create<string>({ 'capacity': 2 });

ring.push('a');
ring.push('b');
ring.push('c'); // 'a' is evicted

console.log(`evicted items: ${ring.evictedItems.join(', ')}`);
console.log(`ring length: ${ring.length}`);

// Opt-in grow mode — observe capacity changes
class GrowTracker<T> extends CircularBuffer<T> {
  readonly growEvents: number[] = [];

  protected override onGrow(_old: number, newCap: number): void {
    this.growEvents.push(newCap);
  }
}

const growing = GrowTracker.create<string>({ 'capacity': 2, 'overflow': 'grow' });

growing.push('a');
growing.push('b');
growing.push('c'); // triggers grow: capacity 2 → 4

console.log(`grow events: ${growing.growEvents.length}`);
console.log(`new capacity after grow: ${growing.growEvents[0]}`);

Observability hooks

Override any protected hook to observe lifecycle events without coupling to a logger or metrics library.

HookWhen it firesArgs
onOverflow(item)Push onto a full buffer in overwrite mode, before the oldest item is evicteditem: T — the incoming item
onEvict(item)Push onto a full buffer in overwrite mode, after overflow is detected, before the slot is overwrittenitem: T — the item being dropped
onPush(item)End of push(), after the item is inserted and length updated (fires in both modes)item: T — the item pushed
onShift(item)Inside shift(), before returning the item (not called on empty buffer)item: T — the item being removed
onGrow(oldCapacity, newCapacity)End of grow(), after the buffer has been resized (grow mode only)oldCapacity: number, newCapacity: number
ts
import { CircularBuffer } from '../src/index.js';

class TracingBuffer<T> extends CircularBuffer<T> {
  readonly overflowItems: T[] = [];
  readonly evictItems: T[] = [];
  readonly growEvents: { 'newCapacity': number; 'oldCapacity': number }[] = [];
  readonly pushItems: T[] = [];
  readonly shiftItems: T[] = [];

  protected override onOverflow(item: T): void {
    console.log(`[circular-buffer] overflow incoming=${String(item)}`);
    this.overflowItems.push(item);
  }

  protected override onEvict(item: T): void {
    console.log(`[circular-buffer] evict item=${String(item)}`);
    this.evictItems.push(item);
  }

  protected override onGrow(oldCapacity: number, newCapacity: number): void {
    console.log(`[circular-buffer] grow ${oldCapacity} → ${newCapacity}`);
    this.growEvents.push({ 'newCapacity': newCapacity, 'oldCapacity': oldCapacity });
  }

  protected override onPush(item: T): void {
    console.log(`[circular-buffer] push item=${String(item)} length=${this.length}`);
    this.pushItems.push(item);
  }

  protected override onShift(item: T): void {
    console.log(`[circular-buffer] shift item=${String(item)} length=${this.length}`);
    this.shiftItems.push(item);
  }
}

// Scenario 1: capacity-3 overwrite buffer, push 5 items (2 overflows/evictions)
console.log('--- overwrite mode (capacity 3) ---');
const ring = TracingBuffer.create<number>({ 'capacity': 3 });
ring.push(1);
ring.push(2);
ring.push(3);
ring.push(4); // overflow: evicts 1
ring.push(5); // overflow: evicts 2

console.log('shifting all remaining items:');
while (ring.length > 0) { ring.shift(); }

// Scenario 2: capacity-2 grow-mode buffer
console.log('--- grow mode (capacity 2) ---');
const growing = TracingBuffer.create<number>({ 'capacity': 2, 'overflow': 'grow' });
growing.push(10);
growing.push(20);
growing.push(30); // triggers grow 2 → 4

console.log('observedCircularBuffer: scenarios complete');

The base class never calls any logger or metrics library. All hooks are no-ops by default.

Source on GitHub