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-sprite-cache

v1.0.0

Published

Zero-GC off-thread ImageBitmap loader with strict VRAM limits, URL deduplication, and LRU eviction to prevent mobile browser crashes.

Downloads

126

Readme

@zakkster/lite-sprite-cache

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

Zero-GC off-thread ImageBitmap loader with strict VRAM budgeting, LRU eviction, URL deduplication, ref-counted bitmap lifecycle, and Safari fallback destruction. Zero dependencies. One class. Prevents iOS mobile browser crashes.

Why lite-sprite-cache?

| Feature | lite-sprite-cache | PixiJS | Phaser | HTMLImageElement | createImageBitmap | |---|---|---|---|---|---| | Off-thread decode | Yes | Yes | Yes | No | Yes | | URL deduplication | Yes | No | No | No | No | | ID deduplication | Yes | No | No | No | No | | Ref-counted VRAM | Yes | No | No | No | No | | LRU eviction | Yes | No | No | No | No | | Memory budget enforcement | Yes | No | No | No | No | | DPR-aware prerender | Yes | No | No | No | No | | Safari fallback handling | Yes | No | No | No | No | | Zero-GC hot-path .get() | Yes | Yes | No | Yes | Yes | | Fetch timeout + abort | Yes | No | No | No | No | | MIME validation | Yes | No | No | No | No |

Installation

npm install @zakkster/lite-sprite-cache

Quick Start

import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache({ maxMemoryMB: 150 });

// Load assets
await cache.load('hero', '/sprites/hero.png');
await cache.load('enemy', '/sprites/enemy.png');

// In RAF loop — O(1), zero allocations
function draw() {
    const hero = cache.get('hero');
    if (hero) ctx.drawImage(hero, x, y);
    requestAnimationFrame(draw);
}

The VRAM Pipeline

Off-Thread Decode + URL Deduplication

When load(id, url) is called, three deduplication gates fire in order:

| Gate | Check | Result | |---|---|---| | Cache hit | id already in cache | Return bitmap immediately. O(1). | | ID dedup | id already in-flight | Return the existing promise. Zero redundant fetches. | | URL dedup | Another id is fetching the same url | Share the fetch promise. One decode for N consumers. |

Only if all three gates miss does a new fetch + createImageBitmap pipeline begin. The bitmap is decoded off the main thread — no jank in the RAF loop.

VRAM Budgeting + LRU Eviction

Every stored bitmap's VRAM cost is estimated as width × height × 4 bytes (RGBA). When a new asset would exceed maxMemoryMB, the LRU eviction loop removes the least-recently-accessed asset and repeats until the new asset fits.

Access timestamps are updated on every load() cache hit and every get() call — meaning assets used every frame are never evicted, while level-transition textures naturally age out.

Ref-Counted Lifecycle

When two IDs load the same URL, they share a single bitmap via URL deduplication. The bitmap's reference count tracks how many IDs point to it. dispose(id) decrements the ref-count — the bitmap is only flushed from VRAM when the count reaches zero.

VRAM flushing uses bitmap.close() on Chromium/Firefox, with a Safari fallback that zeros the bitmap dimensions (width = 0; height = 0).

DPR-Aware Prerendering

prerender() creates an OffscreenCanvas (or DOM canvas fallback) at width × dpr by height × dpr physical pixels. The 2D context is pre-scaled by dpr, so the renderFn callback draws in logical CSS coordinates. The resulting bitmap is transferred to VRAM and cached with the same LRU/ref-counting as fetched assets.

Caveats

  • Immutability. Bitmaps loaded from the same URL are shared via ref-counting. Do not mutate the returned ImageBitmap — mutations bleed to all references.
  • Sync prerender. The renderFn passed to prerender() must be synchronous. Async drawing commands will not be captured in the cached bitmap.

Benchmark Results

Tested on Apple M2 Pro, Chrome 120, 20 sequential loads of 512×512 PNG textures.

Load Latency (20 Loads)

| Loader | Time (ms) | Notes | |---|---:|---| | lite-sprite-cache | ~85 | Off-thread decode + dedup | | createImageBitmap(blob) | ~110 | Fast, no caching | | PIXI.Texture.from() | ~180 | BaseTexture + Resource overhead | | Phaser.Loader | ~260 | Event-driven pipeline | | HTMLImageElement | ~350 | Main-thread decode |

Parallel Loads (10 Simultaneous, Same URL)

| Loader | Time (ms) | Notes | |---|---:|---| | lite-sprite-cache | ~95 | URL dedup collapses 10 loads → 1 decode | | createImageBitmap(blob) | ~120 | Parallel but no dedup | | PIXI.Texture.from() | ~240 | Creates 10 BaseTextures | | Phaser.Loader | ~300 | Sequential-ish pipeline | | HTMLImageElement | ~500 | Main-thread decode bottleneck |

VRAM Upload Cost (512×512 PNG)

| Loader | Upload Time | Notes | |---|---|---| | lite-sprite-cache | ~0.2 ms | ImageBitmap → GPU direct | | createImageBitmap(blob) | ~0.2 ms | Same | | PIXI.Texture.from() | ~0.8 ms | Texture wrapping overhead | | Phaser.Loader | ~1.0 ms | Texture manager overhead | | HTMLImageElement | ~1.5 ms | CPU → GPU copy |


Recipes

import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache({ maxMemoryMB: 150 });

const levelAssets = [
    { id: 'bg',     url: '/levels/forest/bg.png' },
    { id: 'ground', url: '/levels/forest/ground.png' },
    { id: 'hero',   url: '/sprites/hero.png' },
    { id: 'enemy',  url: '/sprites/goblin.png' },
];

await cache.loadAll(levelAssets);
// All assets cached — start the game loop

prerender() snapshots the canvas into an ImageBitmap the instant renderFn returns. The callback must be purely synchronous — any setTimeout, requestAnimationFrame, await, or deferred drawing inside the callback will not be captured. The bitmap will snapshot a blank or partially-drawn canvas.

import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache();

// ✅ CORRECT — fully synchronous drawing commands
await cache.prerender('ember', 64, 64, (ctx, w, h) => {
    const grad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w/2);
    grad.addColorStop(0, 'rgba(255, 180, 50, 1)');
    grad.addColorStop(0.5, 'rgba(255, 80, 20, 0.5)');
    grad.addColorStop(1, 'rgba(255, 30, 0, 0)');
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, w, h);
}, { dpr: window.devicePixelRatio });

// ❌ WRONG — async renderFn captures a blank canvas:
// await cache.prerender('tex', 64, 64, async (ctx, w, h) => {
//     const img = await loadSomeImage(); // Too late — bitmap already snapshotted
//     ctx.drawImage(img, 0, 0);
// });

// Use in particle system — zero Canvas API calls per frame
const ember = cache.get('ember');
if (ember) ctx.drawImage(ember, x, y);

During level transitions, always call .dispose() explicitly on outgoing scene assets. Do not rely on LRU eviction via maxMemoryMB to clean up — during massive scene swaps, the incoming assets are loaded before the LRU loop catches up, which can momentarily spike VRAM above the crash threshold on iOS.

import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache({
    maxMemoryMB: 150,
    onEvict: (id) => console.log(`LRU evicted: ${id}`)
});

// Load scene A
await cache.loadAll(sceneA_Assets);

// ✅ CORRECT — explicit teardown before loading the next scene.
// VRAM is freed immediately. The incoming scene loads into clean headroom.
function transitionToSceneB() {
    sceneA_Assets.forEach(a => cache.dispose(a.id));
    return cache.loadAll(sceneB_Assets);
}

// ❌ WRONG — loading scene B without disposing scene A first.
// Both scenes coexist in VRAM briefly. On a 150MB budget with two
// 80MB scenes, this spikes to 160MB before LRU eviction kicks in.
// On iOS Safari, that spike is enough to crash the tab.

dispose() vs unloadUnused(): Use dispose() for deterministic teardowns where you know exactly which assets to free (level transitions, cutscene exits). Use unloadUnused(maxAgeMs) for gradual background reclamation in open-world or idle scenarios — it scans all access timestamps and evicts anything stale.

import { SpriteCache } from '@zakkster/lite-sprite-cache';

// iOS Safari crashes at ~200–250MB VRAM — stay under 150MB
const cache = new SpriteCache({
    maxMemoryMB: 150,
    fetchTimeoutMs: 8000, // Tight timeout for mobile networks
});

// LRU eviction automatically prevents crashes
// Assets accessed every frame (hero, UI) survive
// Background textures age out naturally

When multiple entities, components, or particle emitters request the same texture simultaneously, SpriteCache collapses all concurrent fetches into a single network request and a single createImageBitmap decode. This is critical in ECS architectures where hundreds of entities may initialize in the same frame.

import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache({ maxMemoryMB: 150 });

// 50 entities spawn simultaneously, all requesting the same texture.
// Without deduplication: 50 fetches, 50 decodes, 50× bandwidth.
// With SpriteCache: 1 fetch, 1 decode, 50 cache entries sharing 1 bitmap.
const batch = Array.from({ length: 50 }, (_, i) =>
    cache.load(`enemy-${i}`, '/sprites/goblin.png')
);

await Promise.all(batch);

// Proof: only 1 bitmap in VRAM, ref-counted 50 times
console.log(cache.stats().items); // 50 cache entries
// But estimated VRAM = 1 bitmap's worth, not 50×
import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache({ maxMemoryMB: 256 });

// Every 10 seconds, flush assets unused for 30s
setInterval(() => cache.unloadUnused(30000), 10000);
import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache({ maxMemoryMB: 200 });

function renderStats() {
    const { items, pending, memoryMB, maxMemoryMB } = cache.stats();
    hud.textContent = `${memoryMB}MB / ${maxMemoryMB}MB | ${items} cached | ${pending} loading`;
    requestAnimationFrame(renderStats);
}
requestAnimationFrame(renderStats);
import { SpriteCache } from '@zakkster/lite-sprite-cache';

const cache = new SpriteCache();
const dpr = window.devicePixelRatio || 1;

// Prerender a full sprite atlas at device resolution
await cache.prerender('atlas', 512, 512, (ctx, w, h) => {
    for (let row = 0; row < 8; row++) {
        for (let col = 0; col < 8; col++) {
            drawFrame(ctx, col * 64, row * 64, 64, 64, row * 8 + col);
        }
    }
}, { dpr });

// In RAF loop — draw from atlas with source rect
const atlas = cache.get('atlas');
if (!atlas) return; // Always guard — never assume the asset survived eviction
ctx.drawImage(atlas, sx, sy, sw, sh, dx, dy, dw, dh);

API

new SpriteCache(options?)

| Option | Type | Default | Description | |---|---|---|---| | maxMemoryMB | number | 200 | VRAM budget in MB. LRU eviction fires when exceeded. | | fetchTimeoutMs | number | 15000 | Fetch timeout in ms. Aborts stalled requests. | | onEvict | function | null | null | Callback fired on LRU eviction. Receives evicted id. |

Methods

| Method | Returns | Description | |---|---|---| | .load(id, url) | Promise<ImageBitmap \| null> | Load with URL + ID deduplication. Null on failure. | | .loadAll(assets) | Promise<(ImageBitmap \| null)[]> | Parallel batch load. assets = [{ id, url }]. | | .prerender(id, w, h, fn, opts?) | Promise<ImageBitmap \| null> | Cache sync canvas drawing as bitmap. opts = { dpr }. | | .get(id) | ImageBitmap \| undefined | O(1) hot-path retrieval. Updates LRU timestamp. | | .unloadUnused(maxAgeMs?) | void | Evict assets idle longer than maxAgeMs. Default 30000. | | .dispose(id) | void | Decrement ref-count. Flush VRAM at zero. No-op if missing. | | .stats() | SpriteCacheStats | Snapshot: { items, pending, memoryMB, maxMemoryMB }. | | .clearAll() | void | Dispose all cached assets. |


License

MIT

Part of the @zakkster ecosystem

Zero-GC, deterministic, tree-shakeable micro-libraries for high-performance web applications.