An array is not a queue

What it costs to use a plain JavaScript array as an event queue in a React Native app, and how a ring buffer avoids it.

Many front-end and mobile developers reach for [] whenever they need a queue. It works, and most of the time nobody notices the cost.

A JavaScript array is a dynamic, resizable list. Adding or removing at the end, with push and pop, takes amortized constant time. Removing from the front or the middle, with shift and splice, takes time proportional to the length of the array.

The problem

Take an event queue in a React Native app. Events are pushed in at one end and taken out at the other with shift().

  • Each shift() is O(n). It reindexes the whole array. With a high volume of events, the total work grows quadratically.
  • The garbage collector works harder. Every element taken out becomes eligible for collection, which adds allocation pressure and triggers pauses.
  • The user feels it. Those pauses can run from tens to hundreds of milliseconds. Scrolling and animations stutter.

This kind of debt is hard to see. Nothing looks wrong in the code, and the symptom shows up somewhere else, as a slow interface or a memory spike in production.

A ring buffer

A ring buffer, or circular buffer, has a fixed capacity and does both operations in constant time. When it's full, the newest entry overwrites the oldest. Nothing is shifted and nothing extra is allocated.

class RingBuffer<T> {
  private items: (T | undefined)[];
  private head = 0;
  private size = 0;

  constructor(private capacity: number) {
    this.items = new Array(capacity);
  }

  push(item: T) {
    const tail = (this.head + this.size) % this.capacity;
    this.items[tail] = item;
    if (this.size < this.capacity) this.size += 1;
    else this.head = (this.head + 1) % this.capacity;
  }

  shift(): T | undefined {
    if (this.size === 0) return undefined;
    const item = this.items[this.head];
    this.items[this.head] = undefined;
    this.head = (this.head + 1) % this.capacity;
    this.size -= 1;
    return item;
  }
}

With a fixed size you get two things.

  • Predictable throughput. Constant-time operations keep the delay between an event arriving and being handled stable.
  • Less pressure on the heap. Fewer temporary objects means fewer collection cycles and shorter pauses.

The trade is that a full buffer drops its oldest entries. That's fine for things like logs, analytics events or sensor readings. It's wrong for a queue where every item has to be processed.

Choosing the structure

The ring buffer is one example. The broader habit is to pick the data structure from what the code has to do.

  • Know your requirements. What throughput do you need? How many items will you hold at once?
  • Measure before you optimize. Profile the code and find the real hotspots. Don't guess.
  • Match the structure to the need. Use a ring buffer, a deque or a linked list for queues. Use hash-based maps and sets to check membership. Use a heap for priority work.
  • Swap, measure, refine. No structure is perfect. Put one in, measure the gain, and adjust.

Pick the data structure from what the code has to do, and measure before you change it.