npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-history-buffer

v1.0.0

Published

Zero-GC, typed-array history ring buffer for time-travel interpolation.

Readme

@zakkster/lite-history-buffer

npm version npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

⏳ What is lite-history-buffer?

@zakkster/lite-history-buffer is the state-history layer of the Lite ecosystem: a fixed-capacity ring buffer of timestamped snapshots, packed into a single Float32Array, with linear interpolation between the two snapshots that bracket any target time.

You record(time, x, y, ...) once per simulation tick and sample(targetTime, out) once per render frame. Between those two calls, every byte of memory used has already been allocated. Zero heap allocations on the hot path. Zero GC pressure. Zero closures created.

This is the data structure that lives behind:

  • 🎮 Render-tick / simulation-tick decoupling — physics at 30 Hz, rendering at 144 Hz, smooth in between.
  • 🌐 Snapshot interpolation for netcode — Quake-style smoothing of remote player state arriving over a jittery network.
  • Rollback / replay — keep the last N frames of state for resimulation, scrubbing, or determinism debugging.
  • 👆 High-frequency input history — "where was the cursor 50 ms ago?" without allocating a {t, x, y} object on every pointer event.

Sub-1 KB min+gzip. Zero dependencies.

🧬 Where it fits

lite-history-buffer is the temporal memory layer of a real-time loop — the bridge between your fixed-rate state producer and your variable-rate state consumer:

flowchart LR
    A[Simulation / Physics<br/><sub>fixed Hz</sub>]:::sim --> B[lite-history-buffer<br/><sub>record snapshots</sub>]:::core
    N[Network snapshots<br/><sub>jittery</sub>]:::net --> B
    I[Input events<br/><sub>variable Hz</sub>]:::input --> B
    B --> C[sample at any time t]:::out
    C --> D[lite-ease / lite-cubic-bezier<br/><sub>shape the curve</sub>]:::sibling
    C --> E[Renderer<br/><sub>variable Hz</sub>]:::render
    D --> E

    classDef sim fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef net fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef input fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef core fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
    classDef out fill:#f0fdf4,stroke:#16a34a,color:#14532d
    classDef sibling fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
    classDef render fill:#f3f4f6,stroke:#6b7280,color:#1f2937

Every library in the chain is independent. lite-history-buffer produces plain numbers — no framework lock-in.

🚀 Install

npm install @zakkster/lite-history-buffer

🕹️ Quick Start

Render-tick decoupling

Physics ticks at a fixed rate; the renderer needs a position at the current frame time.

import { HistoryBuffer } from '@zakkster/lite-history-buffer';

// Track 2D position over the last 30 simulation snapshots
const positions = new HistoryBuffer({ capacity: 30, stride: 2 });

// Simulation tick (e.g. 30 Hz)
function physicsTick(now) {
    updatePhysics();
    positions.record(now, entity.x, entity.y);
}

// Render tick (e.g. 144 Hz)
const out = new Float32Array(2); // allocated once, reused forever
function render(now) {
    positions.sample(now - 16, out); // sample slightly behind for safety margin
    drawAt(out[0], out[1]);
}

Netcode snapshot interpolation

Server sends authoritative state every ~50 ms; render at 60 fps.

import { HistoryBuffer } from '@zakkster/lite-history-buffer';

// Buffer ~10 server snapshots of (x, y, rotation)
const remote = new HistoryBuffer({ capacity: 16, stride: 3 });

socket.on('state', ({ t, x, y, rot }) => {
    remote.record(t, x, y, rot);
});

const out = new Float32Array(3);
function renderRemote(localNow) {
    // Render 100 ms behind the latest snapshot — Quake-style interpolation delay
    remote.sample(localNow - 100, out);
    drawShip(out[0], out[1], out[2]);
}

Rollback netcode primitive

Keep the last N frames of state, rewind on remote-input arrival, resimulate forward.

import { HistoryBuffer } from '@zakkster/lite-history-buffer';

// 8 frames of latency tolerance × packed (health, mana, x, y)
const stateHistory = new HistoryBuffer({ capacity: 8, stride: 4 });

function tick(frame) {
    advanceSimulation();
    stateHistory.record(frame, player.health, player.mana, player.x, player.y);
}

const snapshot = new Float32Array(4);
function rollbackTo(frame) {
    stateHistory.sample(frame, snapshot);
    player.health = snapshot[0];
    player.mana   = snapshot[1];
    player.x      = snapshot[2];
    player.y      = snapshot[3];
}

🧠 Why this exists

The naïve idiomatic JavaScript solution to "remember the last N states" is something like:

const history = [];
function record(t, x, y) {
    history.push({ t, x, y });
    if (history.length > 60) history.shift();
}

That code does the right thing — and at scale, it falls over. Every push allocates a fresh object. Every shift re-indexes the array. At 60 fps tracking even a hundred entities, that's tens of thousands of objects per second churning through V8's young-generation GC. The result: periodic major-GC pauses that drop frames at exactly the moments your renderer cares about smoothness most.

lite-history-buffer solves this with three moves:

  1. One contiguous Float32Array, sized at construction. Frames live as [time, v0, v1, …] packed end-to-end. Cache-friendly. CPU-friendly. SIMD-adjacent.
  2. Ring-buffer overwrite — when full, the next record() clobbers the physically-oldest slot. No shifting, no copying, no growth.
  3. Discrete numeric arguments for record(time, v0, v1, v2, v3) and a caller-owned out for sample(). Both APIs are designed so V8 never has to box, never has to allocate, and never has a reason to deopt.

The result: one allocation total (the Float32Array), regardless of how long the buffer runs. That's the whole point.

🔥 Algorithm

Sampling a target time runs a backwards walk from the newest frame. For the typical case ("the renderer is just behind simulation"), it terminates in O(1). The worst case — past-clamping deep into the buffer — is O(N), which is still negligible for the buffer sizes this library is designed for (a few dozen frames).

flowchart TD
    A[sample t, out]:::start --> B{count == 0?}
    B -->|yes| C[return — out untouched]:::trivial
    B -->|no| D[read newest frame]:::core
    D --> E{t ≥ newestTime?}
    E -->|yes| F[write newest → out<br/><sub>future-clamp</sub>]:::out
    E -->|no| G[walk backwards<br/><sub>newestIdx → newestIdx-1 → …</sub>]:::walk
    G --> H{prev.time ≤ t?}
    H -->|yes| I[lerp prev → newest<br/><sub>per stride slot</sub>]:::lerp
    H -->|no| J[shift newest := prev<br/>continue walk]:::iter
    J --> G
    G -->|exhausted N-1 steps| K[write oldest → out<br/><sub>past-clamp</sub>]:::out
    I --> Z[done]:::done
    F --> Z
    K --> Z

    classDef start fill:#e0f2fe,stroke:#0284c7
    classDef trivial fill:#f3f4f6,stroke:#6b7280
    classDef core fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef walk fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef iter fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef lerp fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
    classDef out fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef done fill:#f0fdf4,stroke:#16a34a,color:#14532d

Key invariants:

  • Recorded timestamps are monotonically non-decreasing. Out-of-order writes (network jitter, clock corrections) are silently clamped up to the current lastTime. This guarantees that the bracketing search in sample() always terminates correctly.
  • The walk treats newestIdx as a rolling cursor — at every step, it represents the more-recent of the two adjacent samples being compared, so the lerp pair is always already loaded.
  • candidateOldestIdx is the past-clamp fallback target, updated on every walk step. When the loop runs out of frames without finding a bracket, this points to the oldest retained frame.
  • The hot loop's body is pure scalar math: integer mod, two array reads, one subtract, one divide, one multiply-add per stride slot.

🏗️ Buffer layout

A HistoryBuffer({ capacity: 4, stride: 2 }) allocates a 12-element Float32Array laid out as:

slot 0:   [ time₀ | v0₀ | v1₀ ]
slot 1:   [ time₁ | v0₁ | v1₁ ]
slot 2:   [ time₂ | v0₂ | v1₂ ]
slot 3:   [ time₃ | v0₃ | v1₃ ]
            ↑       ↑     ↑
          frame    stride values

Internal cursors:

| Field | Meaning | |---|---| | head | Index of the slot the next record() will write to. | | count | Number of populated frames; saturates at capacity. | | lastTime | Highest timestamp ever passed to record(); used to clamp out-of-order writes. |

The newest valid frame is at (head - 1 + capacity) % capacity. Once count === capacity, the oldest valid frame is at head itself (the slot that's about to be overwritten next).

The buffer property is exposed publicly so advanced consumers (compute kernels, batched lerpers, ECS world serializers) can read frames directly without going through sample(). Mutating it desyncs the cursors — treat it as read-only by convention.

📊 Comparison

| | lite-history-buffer | naïve [] of objects | Map<time, value> | circular-buffer (npm) | |---|---|---|---|---| | Hot-path allocations | 0 | 1 per record + reshuffle on shift | 2+ per set | varies | | Backing storage | Float32Array | array of plain objects | hash table | varies | | Built-in time interpolation | yes (linear) | manual | manual | no | | Monotonic-time clamp | yes | manual | manual | no | | ECS / SoA friendly | yes | no | no | no | | Cache-locality | excellent (packed) | poor (object pointers) | poor (hash buckets) | varies | | TypeScript types | yes (full) | n/a | n/a | varies | | Bundle (min+gzip) | <1 KB | 0 | 0 | varies |

lite-history-buffer is not trying to be a general-purpose timeseries database. It's the kernel that does one thing well: keep the last N timestamped snapshots and let you sample any past moment without bothering the GC.

⚙️ API

new HistoryBuffer(config)

new HistoryBuffer({ capacity: number, stride: number })

| Param | Type | Description | |---|---|---| | capacity | number | Max snapshots retained. Must be > 0. | | stride | number | Values per snapshot. Must be >= 0. record() accepts up to 4 discrete values; for higher strides write to buffer directly. |

Throws Error if capacity <= 0 or stride < 0.

record(time, v0?, v1?, v2?, v3?): void

Record a snapshot at the given time.

| Param | Type | Description | |---|---|---| | time | number | Timestamp. If less than lastTime, silently clamped up. | | v0…v3 | number? | Up to 4 discrete values. Unused slots default to 0. |

Zero allocations.

sample(targetTime, out): void

Sample the buffer at targetTime, writing into out.

| Param | Type | Description | |---|---|---| | targetTime | number | Time to sample at. | | out | Float32Array \| number[] | Caller-owned destination. Must have length >= stride. Only [0..stride-1] are written. |

Behaviour:

  • Empty buffer → out untouched.
  • targetTime >= newest → newest frame written verbatim.
  • targetTime <= oldest → oldest frame written verbatim.
  • Otherwise → component-wise linear interpolation between the bracketing frames.

Zero allocations.

Public properties

readonly capacity:  number;       // as configured
readonly stride:    number;       // as configured
readonly frameSize: number;       // stride + 1
readonly buffer:    Float32Array; // raw backing storage

⚡ Performance characteristics

| Operation | Cost | |---|---| | record() | 1 modulo, 1 max, 1 + stride array writes — single-digit ns | | sample() near-newest (typical) | 1 comparison, ≤2 array reads + lerp | | sample() deep / past-clamp | linear walk over up to count - 1 frames | | sample() empty buffer | 1 comparison | | Memory footprint | capacity × (stride + 1) × 4 bytes | | Total allocations | one (the Float32Array, at construction) | | Hidden classes | one stable shape per HistoryBuffer instance |

🛡️ Validation

This library trusts its inputs. The constructor validates capacity and stride; everything else is "garbage in, garbage out" by design:

| Input | Behaviour | |---|---| | capacity <= 0 or stride < 0 | Constructor throws. | | time less than lastTime | Silently clamped up to lastTime. | | time === NaN | Propagates to lastTime; subsequent samples will degrade to past-clamp. | | stride > 4 | Accepted, but record() only writes the first 4 values. | | out.length < stride (number array) | The array is grown silently (still zero-allocation if you reuse it). | | out.length < stride (Float32Array) | Trailing writes are silently dropped. |

Validation belongs at the boundary of your application — at the network parser, at the input handler — not at the bottom of every render tick.

Time precision

Timestamps are stored as Float32. For typical relative-time use (deltas of seconds or minutes) this is more than precise enough. For absolute performance.now() timestamps after many hours of continuous runtime, sub-millisecond precision degrades — pass relative time, or store time as a separate Float64Array if you need that range.

📦 TypeScript

Full TypeScript declarations included in HistoryBuffer.d.ts.

import { HistoryBuffer } from '@zakkster/lite-history-buffer';
import type { HistoryBufferConfig } from '@zakkster/lite-history-buffer';

const config: HistoryBufferConfig = { capacity: 60, stride: 2 };
const hb = new HistoryBuffer(config);

const out: Float32Array = new Float32Array(2);
hb.record(performance.now(), 1.0, 2.0);
hb.sample(performance.now() - 50, out);

📚 LLM-friendly documentation

See llms.txt for a structured reference designed for AI coding assistants. Covers the public surface, the algorithm, integration patterns, and common pitfalls in one parseable document.

🧩 Pairs well with

License

MIT © Zahary Shinikchiev