Cache Prefetching Specification
Version: 1.2.1 Last Updated: 2026-09-28 Status: Implemented
Current architecture (2026-07). The store class is
MultiLevelCachingStore(packages/luxar-viewer/src/cache/multi-level-caching-store.ts). The L1 (memory LRU) / L2 (OPFS) / L3 (remote HTTP) tiers described below are the compressed-chunk path the prefetcher operates on; the full cache system has since grown additional tiers ABOVE it — an L0 decompressed-chunk cache (decoded TypedArrays, skips Blosc) and the S-cache (SliceCache, fully decoded per-slice geometry ladders) — which prefetching does not touch. Seepackages/luxar-viewer/src/cache/README.mdfor the live S-cache/L0/L1/L2 + HTTP picture and heap-aware budgets. TheChunkPrefetcherpart of this spec matches the implementation (packages/luxar-viewer/src/cache/chunk-prefetcher.ts).
Overview
This document specifies a prefetching system for the Luxar viewer’s multi-level cache. The goal is to reduce perceived latency by proactively fetching adjacent zarr chunks before they are explicitly requested.
Motivation
When viewing zarr data, access patterns exhibit strong spatial and temporal locality:
Spatial locality: Viewing a 3D region means adjacent chunks are likely needed next
Temporal locality: nD slicing often iterates sequentially through time/channel dimensions
Network latency: L3 (HTTP) fetches are ~100ms, making latency hiding valuable
By prefetching adjacent chunks after cache hits, we can hide network latency and improve perceived performance.
Architecture
Design Decision: Separate PrefetchManager
The prefetching logic is implemented as a separate ChunkPrefetcher class rather than inline in MultiLevelCachingStore. This provides:
Separation of concerns - Store remains a simple zarr backend
Testability - Prefetch logic can be unit tested in isolation
Configurability - Prefetching can be disabled/tuned independently
Extensibility - Smarter heuristics can be added without touching the store
Component Diagram
┌─────────────────────────────────────────────────────────────────┐
│ MultiLevelCachingStore │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ L1 Cache │───►│ L2 Cache │───►│ L3 HTTP │ │
│ │ (Memory) │ │ (OPFS) │ │ (Remote) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │
│ └───────────────┴───────────────┘ │
│ │ │
│ onAccess(key, hitLevel) │
└────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ChunkPrefetcher │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Parse Chunk │───►│ Calculate │───►│ Enqueue │ │
│ │ Indices │ │ Adjacent │ │ Prefetch │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Process Queue │ │
│ │ (max 4 concurrent)│ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Trigger Points
Prefetching is triggered at the L2/L3 boundary, not on L1 hits:
Event |
Action |
Rationale |
|---|---|---|
L1 hit |
No prefetch |
Data already fast (~1μs), not worth overhead |
L2 hit |
Prefetch adjacent from L3 → L2 + L1 |
Hide future L3 latency |
L3 fetch |
Prefetch adjacent from L3 → L2 + L1 |
Already paying network cost, batch it |
Why Not Prefetch on L1 Hit?
L1 hits are extremely fast (~1μs). The overhead of calculating adjacent chunks and checking if they need prefetching would exceed any potential benefit. Additionally, L1 hits indicate hot data that’s likely already been prefetched.
Adjacent Chunk Calculation
Chunk Key Formats
Zarr chunks use two naming conventions:
Zarr v2 (dot notation): points/positions/0.1.2
Zarr v3 (path notation): points/positions/c/0/1/2
Both encode the same information: array path + chunk indices.
Adjacency Definition
For a chunk at indices [i, j, k, ...], adjacent chunks are defined as symmetric ±1 in each dimension:
Current: [2, 3, 1]
Adjacent: [1, 3, 1], [3, 3, 1], // ±1 in dim 0
[2, 2, 1], [2, 4, 1], // ±1 in dim 1
[2, 3, 0], [2, 3, 2] // ±1 in dim 2
Design Decision: Symmetric (±1) rather than forward-only (+1) because:
Most arrays are 1D anyway (positions, colors, radii)
Chunking reduces effective dimensionality
User navigation can go in either direction
Symmetric is simpler to implement and reason about
Boundary Handling
Adjacency prefetch requires a loader to register the array’s chunk grid with
ChunkPrefetcher.registerArrayBounds(arrayPath, shape, chunks). Non-array or
empty shape/chunks, mismatched ranks, or nonpositive/nonfinite chunk sizes
skip registration without failing the load.
An array without registered bounds gets no adjacency prefetch.
Negative indices are skipped (no chunk at [-1, 0, 0]).
Indices at or beyond
ceil(shape / chunks)in each dimension are skipped.Dimensions containing only one chunk have no adjacent chunks to prefetch.
The points, lines, and gsplats spatial-index loaders register element arrays
through registerPointsArrayBounds, registerLinesArrayBounds, and
registerGSplatsArrayBounds in their respective chunk-index-loader.ts files.
Points register positions, colors, radii, sharpnesses, and scalars;
lines register vertices, segments, widths, colors, sharpnesses, and
scalars; gsplats register centers, amplitudes, colors, label_ids, and
the Cholesky arrays. Optional arrays are registered only when present. Mesh
arrays, chunk_bounds, and colormap LUTs are not registered and therefore get
no adjacency prefetch. The loader call sites are the authoritative list.
Chunk Index Parsing
/**
* Parse chunk indices from a zarr chunk key.
*
* @example
* parseChunkIndices('points/positions/0.1.2') → [0, 1, 2]
* parseChunkIndices('points/positions/c/0/1/2') → [0, 1, 2]
* parseChunkIndices('.zattrs') → null (not a chunk)
*/
function parseChunkIndices(key: string): number[] | null {
// Check for v2 dot notation: ends with digits separated by dots
const v2Match = key.match(/\/(\d+(?:\.\d+)*)$/);
if (v2Match) {
return v2Match[1].split('.').map(Number);
}
// Check for v3 path notation: /c/ followed by path segments
const v3Match = key.match(/\/c\/(\d+(?:\/\d+)*)$/);
if (v3Match) {
return v3Match[1].split('/').map(Number);
}
return null; // Not a chunk key (metadata file)
}
Adjacent Key Generation
This snippet illustrates neighbor generation with maxIndices passed explicitly.
The prefetcher’s private method takes only key and looks up those counts from
the bounds registered for its array path.
/**
* Generate adjacent chunk keys (±1 in each dimension) using the given chunk counts.
*
* @example
* getAdjacentChunks('points/positions/1.2.3', [4, 5, 5])
* → ['points/positions/0.2.3', 'points/positions/2.2.3',
* 'points/positions/1.1.3', 'points/positions/1.3.3',
* 'points/positions/1.2.2', 'points/positions/1.2.4']
*/
function getAdjacentChunks(key: string, maxIndices: number[] | undefined): string[] {
const indices = parseChunkIndices(key);
if (!indices || !maxIndices) return [];
// Extract base path (everything before the indices)
const isV3 = /\/c\/\d+(\/\d+)*$/.test(key);
const basePath = isV3
? key.replace(/\/c\/[\d/]+$/, '')
: key.replace(/\/[\d.]+$/, '');
const adjacent: string[] = [];
for (let dim = 0; dim < indices.length; dim++) {
if (dim < maxIndices.length && maxIndices[dim] <= 1) continue;
for (const delta of [-1, 1]) {
const newIndices = [...indices];
newIndices[dim] += delta;
// Skip negative indices
if (newIndices[dim] < 0) continue;
// Skip indices at or beyond the registered upper bound
if (dim < maxIndices.length && newIndices[dim] >= maxIndices[dim]) continue;
// Generate key in same format as input
const indexStr = isV3
? 'c/' + newIndices.join('/')
: newIndices.join('.');
adjacent.push(`${basePath}/${indexStr}`);
}
}
return adjacent;
}
Priority Mechanism
The Challenge
JavaScript is single-threaded - there’s no OS-level thread priority. “Lower priority” must be achieved through code behavior, not scheduler hints.
How Priority Works
Aspect |
Normal Request |
Prefetch Request |
|---|---|---|
Blocking |
|
Fire-and-forget - no await |
Concurrency |
Unlimited (browser limit) |
Limited to 4 concurrent |
Error handling |
Propagate to caller |
Silently ignore |
On dispose |
Must complete |
Complete harmlessly in background |
User impact |
Blocks UI/data display |
Invisible to user |
JavaScript Async Model
JavaScript achieves “parallel” I/O through non-blocking operations, not threads:
┌─────────────────────────────────────────────────────────────────┐
│ Browser Process │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ JS Thread │ │ Browser Internals │ │
│ │ (single) │ │ (C++, truly multi-threaded)│ │
│ │ │ │ │ │
│ │ fetch(url) ────┼────────►│ - Network stack │ │
│ │ // returns │ │ - DNS resolution │ │
│ │ // immediately │ │ - TLS handshake │ │
│ │ │ │ - HTTP request/response │ │
│ │ // JS is free │ │ │ │
│ │ // to do other │◄────────┼── Queues callback when done │ │
│ │ // work │ │ │ │
│ └─────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Key insight: fetch() returns immediately. The browser’s network stack (which IS multi-threaded) handles the actual HTTP connection. When the response arrives, a callback is queued for the JS event loop.
// These run "in parallel" (all requests in flight simultaneously)
// but no JS code executes in parallel
const p1 = fetch(url1); // Starts request, returns immediately
const p2 = fetch(url2); // Starts request, returns immediately
const p3 = fetch(url3); // Starts request, returns immediately
// JS thread is FREE here - all 3 requests are in flight
// Browser handles actual network I/O
// Later, collect results
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
Concurrency Limiting
Normal requests go directly to store.get() with no limit. Prefetch requests are queued and limited:
class ChunkPrefetcher {
private store: MultiLevelCachingStore;
private maxConcurrent = 4;
private inFlight = new Set<string>(); // Track in-flight keys
private queue = new Set<string>();
private processing = false;
/**
* Called by store after L2 hit or L3 fetch.
*/
onAccess(key: string): void {
const adjacent = this.getAdjacentChunks(key);
for (const adjKey of adjacent) {
// Full deduplication: not in queue, not in-flight
if (!this.queue.has(adjKey) && !this.inFlight.has(adjKey)) {
this.queue.add(adjKey);
}
}
this.processQueue(); // Fire-and-forget (not awaited)
}
private async processQueue(): Promise<void> {
// Prevent concurrent processing
if (this.processing) return;
this.processing = true;
try {
while (this.queue.size > 0 && this.inFlight.size < this.maxConcurrent) {
const key = this.queue.values().next().value;
this.queue.delete(key);
this.inFlight.add(key);
// Fire-and-forget with cleanup
this.store.get(key)
.catch(() => {}) // Ignore errors (404s, network failures)
.finally(() => {
this.inFlight.delete(key);
// Trigger queue processing when slot frees up
if (this.queue.size > 0) {
queueMicrotask(() => this.processQueue());
}
});
}
} finally {
this.processing = false;
}
}
}
This ensures:
At most 4 prefetch requests in flight
Full deduplication (no duplicate prefetches in queue or in-flight)
Race-condition safe (re-triggers processing when slots free up)
Normal requests face no artificial limit
Browser’s connection pool (~6 for HTTP/1.1, ~100 streams for HTTP/2) has capacity for normal requests
Fetch Priority API (Optional Enhancement)
Modern browsers support the Fetch Priority API as a hint:
// Normal request - default priority
fetch(url);
// Prefetch request - low priority hint
fetch(url, { priority: 'low' });
Browser support:
Chrome/Edge 101+: Supported
Firefox: Not supported (ignored)
Safari: Not supported (ignored)
This is a hint, not a guarantee. Our concurrency limiting is the primary mechanism.
Integration
Store Modifications Required
The current MultiLevelCachingStore needs these additions to support prefetching:
// Add to MultiLevelCachingStore class:
private prefetcher: ChunkPrefetcher | null = null;
/**
* Attach a prefetcher to receive access notifications.
*/
setPrefetcher(prefetcher: ChunkPrefetcher | null): void {
this.prefetcher = prefetcher;
}
// Modify get() method - add prefetch triggers after L2 hit and L3 fetch:
async get(key: string, _options?: any): Promise<Uint8Array | undefined> {
// L1: Memory check (fastest, ~1μs)
const l1Hit = this.l1Cache.get(key);
if (l1Hit) {
// No prefetch on L1 hit - data is already fast
return l1Hit;
}
// L2: OPFS check (~1ms)
if (this.enabled && this.l2Store) {
const l2Hit = await this.l2Store.get(key);
if (l2Hit) {
this.l1Cache.set(key, l2Hit);
// NEW: Trigger prefetch on L2 hit
this.prefetcher?.onAccess(key);
return l2Hit;
}
}
// L3: Remote fetch (~100ms)
try {
const response = await fetch(`${this.baseUrl}/${key}`);
if (!response.ok) return undefined;
const data = new Uint8Array(await response.arrayBuffer());
this.l1Cache.set(key, data);
if (this.enabled && this.l2Store) {
this.l2Store.set(key, data).catch(() => {});
}
// NEW: Trigger prefetch on L3 fetch
this.prefetcher?.onAccess(key);
return data;
} catch {
return undefined;
}
}
// Modify dispose() to clear prefetcher reference:
async dispose(): Promise<void> {
// Clear prefetcher reference (in-flight requests will complete harmlessly)
this.prefetcher = null;
if (this.l2Store) {
await this.l2Store.dispose();
}
this.clearL1();
}
Lifecycle Management
The prefetcher lifecycle is tied to the MultiLevelCachingStore:
// Creation (in scene-loader.ts or similar)
const store = new MultiLevelCachingStore(baseUrl, options);
await store.init();
// Create prefetcher with reference to store
const prefetcher = new ChunkPrefetcher(store, {
maxConcurrent: 4,
enabled: true,
});
// Connect bidirectionally
store.setPrefetcher(prefetcher);
// Usage - normal, prefetching happens automatically
const chunk = await store.get('points/positions/0.0.0');
// Cleanup - dispose store (which cancels prefetches)
await store.dispose();
Configuration
Options
interface ChunkPrefetcherOptions {
/** Maximum concurrent prefetch requests (default: 4) */
maxConcurrent?: number;
/** Enable/disable prefetching (default: true) */
enabled?: boolean;
/** Use Fetch Priority API hint (default: true) */
useFetchPriority?: boolean;
}
URL Parameters
For debugging and testing:
Parameter |
Effect |
|---|---|
|
Disable prefetching entirely |
|
Log prefetch operations to console |
Performance Considerations
Memory Impact
Prefetched data competes for L1 cache space. However:
LRU eviction naturally handles unused prefetches
Prefetched-but-unused data gets evicted first (hasn’t been accessed recently)
L1 size (100MB default) is large enough to absorb reasonable prefetch
Network Impact
With maxConcurrent = 4:
HTTP/1.1 (6 connections): 2 slots reserved for normal requests
HTTP/2 (~100 streams): Plenty of capacity
When Prefetching Hurts
Prefetching can hurt performance when:
Random access patterns - Prefetched data is never used
Bandwidth-constrained - Prefetch competes with needed data
High-latency connections - Queue fills up, stale prefetches
The ?noPrefetch parameter allows disabling for these cases.
Testing Strategy
Unit Tests
Chunk index parsing: Test both v2 and v3 formats
Adjacent calculation: Test various dimensions, boundary cases
Queue management: Test concurrency limiting, deduplication
Integration Tests
L2 hit triggers prefetch: Mock store, verify prefetcher called
L3 fetch triggers prefetch: Mock store, verify prefetcher called
L1 hit skips prefetch: Verify prefetcher NOT called
E2E Tests (Playwright)
Prefetch reduces latency: Measure time to load adjacent chunks
No resource leaks: Verify in-flight requests complete without errors
Debug parameter works: Verify
?noPrefetchdisables feature
Future Enhancements
Directional Prediction
Track recent access patterns to predict prefetch direction:
// If recent accesses are [0,0,0] → [0,0,1] → [0,0,2]
// Predict next: [0,0,3], prefetch more aggressively in dim 2
Adaptive Concurrency
Adjust maxConcurrent based on network conditions:
// If latency is high, reduce prefetch concurrency
// If bandwidth is plentiful, increase it
Prefetch Buffer
Separate prefetched data from L1 to prevent evicting hot data:
// Prefetched data → prefetchBuffer (smaller, separate)
// On access → promote to L1
// Prevents prefetch from evicting actively-used chunks
Changelog
v1.2.0 (2026-07-13): Sync with current cache architecture
Class renamed to match the implementation:
TwoLevelCachingStore→MultiLevelCachingStoreAdded “Current architecture (2026-07)” note: L0 decompressed cache and S-cache (SliceCache) sit above the L1/L2/L3 compressed-chunk path this spec covers; see
packages/luxar-viewer/src/cache/README.md
v1.1.0 (2025-01-06): Critical review and simplification
Fixed race condition in
processQueue()(re-trigger via queueMicrotask when slots free up)Added full deduplication (check both queue and in-flight before adding)
Removed cancellation entirely - in-flight requests complete harmlessly on dispose
Simplified
inFlightfromMap<string, AbortController>toSet<string>Clarified store modifications required for integration
Updated priority comparison table (removed cancellation row)
v1.0.0 (2025-01-06): Initial specification
Separate ChunkPrefetcher architecture
L2/L3 trigger points
Symmetric ±1 adjacency
Concurrency-based priority mechanism
Full async model documentation