Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

crossfilter3

A streaming-first, zero-copy analytics engine for the browser.

Crossfilter3 is built on top of crossfilter2, already the fastest way to filter large datasets client-side. This fork turns it into a complete dashboard runtime — data streams in as Arrow IPC, decodes and filters inside a Web Worker (main thread never blocks), WASM accelerates the hot filter scan, and partial snapshots render the UI progressively before the download even finishes.

What crossfilter3 adds to crossfilter2

LayerWhat changedWhy it matters
IngestColumnar Arrow IPC streaming with batch coalescing (default 64K rows), multi-source lookup joins, projection/rename/type-coercion at ingest, Proxy-backed lazy row arrays that defer materialization via COLUMNAR_BATCH_KEYData goes from Cube.dev (or any Arrow source) straight into crossfilter's sorted indexes without ever building intermediate row objects — a 10K-row dataset allocates zero row objects until a panel actually reads one
FilteringInline WASM module (no external .wasm file) with two scan paths: filterInU32 for small target sets (k ≤ 4, n ≤ 1K) and markFilterInU32 with dense lookup for larger sets; automatic regex extraction of d => d.field and function(d){ return d.field } into string paths for WASM routingFilter scans stay in linear WASM memory instead of JS object traversal — see performance estimates below
Lazy encoded pathUint32 code encoding per dimension (code 0 = null, 1..n = distinct values), 2x-amortized codes buffer growth on append, incremental codeCounts updates, filterRange target-codes caching, groupAll O(1) fast path, scratch buffer reuse across filter cyclesAppends are O(batch) not O(n log n) — a 10K append into 90K existing records skips the full re-sort and re-reduce
AggregationDeclarative KPIs (count, sum, avg, avgNonZero), declarative groups with time bucketing (minute/hour/day/week/month), split-field groups for nested aggregates keyed by a secondary dimension, incremental group updates on appendOne config object replaces dozens of imperative dimension().group().reduce() chains
Worker runtimecreateStreamingDashboardWorker owns fetch → decode → filter → reduce → postMessage with Transferable typed-array buffers (zero-copy back to main thread); createDashboardRuntime for synchronous fallback; single query() round-trip returns filters + snapshot + paged rows + optional rowCount and boundsThe main thread only renders; structured-clone overhead is eliminated for the largest payloads (column arrays)
Query modelDeclarative filters (exact, in, range), isolatedFilters for within-group filtering without affecting global state, rowSets for multiple named row slices per query, bounds queries for min/max, ad-hoc groups queriesOne postMessage round-trip replaces what would otherwise be 4-8 separate calls
Progressive UIPartial snapshot emission during streaming load (throttled at 250ms default), separate fetch-percent and rows-loaded progress events (throttled at 100ms)Charts and KPIs appear within seconds even on million-row datasets
Live mutationappend() slots new rows into existing lazy indexes incrementally; removeFiltered() rebuilds codeCounts safely and re-filtersDashboards stay live without full rebuild
Instance extensionscf.allFilteredIndexes(), cf.isElementFiltered(index), cf.takeColumns(indexes, fields), cf.configureRuntime() / cf.runtimeInfo() for per-instance WASM controlColumnar extraction and filter introspection without materializing rows
The original crossfilter API (cf.dimension(), group.all(), etc.) is fully preserved — everything above is additive.

Performance and memory estimates

These are analytical estimates based on the architecture — not synthetic benchmarks. Actual results depend on dataset shape, dimension cardinality, and browser.

Filter scan throughput (WASM vs JS)

The WASM module operates on flat Uint32Array codes in linear memory. The JS fallback (denseLookupMatches) builds a marks array and iterates in JS. Both do the same work — the difference is memory access pattern and JIT overhead.

OperationJS fallbackWASM (markFilterInU32)Speedup estimate
filterIn on 100K rows, 50 target values~2-4ms~0.5-1.5ms~2-3x
filterIn on 1M rows, 50 target values~20-40ms~5-15ms~2-4x
filterExact on 100K rows~0.5-1ms~0.2-0.5ms~2x

The small-target path (filterInU32, k ≤ 4) uses a tight O(n*k) nested loop — effective for filterExact and small filterIn sets where the marks array setup cost would dominate.

Memory footprint per dimension

ComponentSize for N rowsExample (100K rows)
codes (Uint32Array, 2x capacity)4 × 2N bytes800 KB
codeCounts (Uint32Array)4 × cardinality bytes4 KB (1000 distinct)
codeToValue (Array)~50 × cardinality bytes50 KB
selected (Uint8Array)N bytes100 KB
Filter bitmask (Uint8/16/32)1-4 × N bytes100-400 KB
Total per dimension~6-10 bytes/row~1 MB

Adding a dimension costs ~6-10 bytes per row. The 32-dimension limit (bitmask width) means worst-case overhead is ~320 bytes/row.

Row materialization savings

In upstream crossfilter, every record is a JS object from the start. In this fork, columnar ingest creates Proxy-backed arrays — rows materialize only on access.

ScenarioUpstream (all rows as objects)This fork (columnar + lazy)Savings
10K rows, 20 fields, 50 visible~5 MB (10K objects × ~500 bytes)~25 KB (50 objects) + columnar arrays already in memory~5 MB heap, ~10K fewer GC objects
100K rows, 20 fields, 50 visible~50 MB~25 KB + columnar~50 MB heap
1M rows, 20 fields, 100 visible~500 MB~50 KB + columnar~500 MB heap

The columnar arrays themselves (one typed/string array per field) are the same size either way — the saving is entirely in not creating N row objects with N × fields property slots.

Append performance (lazy path)

OperationUpstreamThis fork (lazy encoded)Why
Append 10K rows to 90KO(n log n) re-sort + full reduceO(m) codes extension + incremental codeCountsCodes buffer grows 2x amortized; existing sorted indexes untouched
groupAll after appendO(n) full scanO(1) mark updateSingleton groups skip sorted-key rebuild

Installation

npm install crossfilter3 apache-arrow

The streaming worker needs UMD bundles of both libraries available at public HTTP URLs. In a Next.js project, copy them into public/ with a postinstall script:

// package.json
{
"scripts": {
"postinstall": "mkdir -p public/vendor && cp node_modules/crossfilter3/crossfilter.js public/vendor/ && cp node_modules/apache-arrow/Arrow.es2015.min.js public/vendor/"
}
}

Then reference them as absolute paths:

construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',// ...});

Quick start

importcrossfilterfrom'crossfilter3';construntime=awaitcrossfilter.createStreamingDashboardWorker({crossfilterUrl: '/vendor/crossfilter.js',arrowRuntimeUrl: '/vendor/Arrow.es2015.min.js',wasm: true,emitSnapshots: true,batchCoalesceRows: 65536,// Declare filterable fields (use post-projection names)dimensions: ['event','country','region','time'],// Global aggregate metricskpis: [{id: 'count',field: 'count',op: 'sum'}],// Pre-computed group-by aggregations for chartsgroups: [{id: 'byEvent',field: 'event',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'byCountry',field: 'country',metrics: [{id: 'count',field: 'count',op: 'sum'}]},{id: 'timeline',field: 'time',bucket: {type: 'timeBucket',granularity: 'month'},metrics: [{id: 'count',field: 'count',op: 'sum'}],},],// Arrow IPC source (fetched inside the worker)sources: [{id: 'primary',role: 'base',dataUrl: '/api/cube',dataFetchInit: {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({format: 'arrow',query: {dimensions: ['events.country','events.event','events.region'],measures: ['events.count'],timeDimensions: [{dimension: 'events.timestamp',granularity: 'month'}],timezone: 'UTC',limit: 1000000,},}),},projection: {rename: {'events.count': 'count','events__count': 'count','events__timestamp_month': 'time','events.event': 'event','events.country': 'country','events.region': 'region',},transforms: {count: 'number',time: 'timestampMs',},},}],});

Progress and streaming snapshots

While data loads, the worker emits progress and partial snapshots so the UI can render progressively:

runtime.on('progress',(progress)=>{console.log(progress.status,progress.load.rowsLoaded,'rows');console.log(progress.fetch.percent,'% downloaded');});runtime.on('snapshot',({ snapshot })=>{renderCharts(snapshot.groups);renderKpis(snapshot.kpis);});awaitruntime.ready;

Querying with filters

After load, send declarative filters and get back pre-computed aggregations plus paged row data in a single worker round-trip:

constresult=awaitruntime.query({filters: {country: {type: 'in',values: ['US','UK']},time: {type: 'range',range: [startMs,endMs]},},rows: {sortBy: 'time',direction: 'top',limit: 50,offset: 0,fields: ['event','country','region','time','count'],},});result.snapshot.kpis;// { count: 54321 }result.snapshot.groups.byEvent;// [{ key: 'click', value: { count: 30000 }}, ...]result.snapshot.groups.timeline;// [{ key: 1704067200000, value: { count: 12000 }}, ...]result.rows;// columnar row data for the table

Filter types

// Exact match{type: 'exact',value: 'click'}// Set membership{type: 'in',values: ['click','view','purchase']}// Range (inclusive lower, exclusive upper){type: 'range',range: [startMs,endMs]}// Clear filter on a fieldnull

Live data mutation

Append rows or remove filtered records without rebuilding the runtime:

awaitruntime.append([{event: 'click',country: 'US',region: 'CA',time: Date.now(),count: 1},]);awaitruntime.removeFiltered('excluded');

Synchronous fallback (no worker)

For smaller datasets or environments where workers are unavailable:

importcrossfilterfrom'crossfilter3';import{tableFromIPC}from'apache-arrow';constbuffer=awaitfetch('/data/result.arrow').then(r=>r.arrayBuffer());consttable=tableFromIPC(newUint8Array(buffer));construntime=crossfilter.createDashboardRuntime({
table,wasm: true,dimensions: ['country','event','time'],groups: [{id: 'byCountry',field: 'country',metrics: [{id: 'count',op: 'count'}]},],kpis: [{id: 'total',op: 'count'}],});constsnapshot=runtime.snapshot({country: {type: 'in',values: ['US']},});

Classic crossfilter API

The original crossfilter API is still fully available:

importcrossfilterfrom'crossfilter3';constcf=crossfilter(records);constcountry=cf.dimension('country');country.filterIn(['US','UK']);constgroup=country.group().reduceCount();console.log(group.all());

Configuration reference

createStreamingDashboardWorker(options)

OptionTypeDescription
crossfilterUrlstringURL to the UMD build (crossfilter.js)
arrowRuntimeUrlstringURL to Apache Arrow UMD (Arrow.es2015.min.js)
sourcesArrayArrow IPC data sources (see below)
dimensionsstring[]Field names to create filterable dimensions on
groupsArrayDeclarative group-by specs
kpisArrayGlobal aggregate metric specs
wasmbooleanEnable WASM-accelerated filter scans (default true)
emitSnapshotsbooleanEmit partial snapshots during streaming load
batchCoalesceRowsnumberBuffer this many rows before flushing to the runtime (default 65536)
progressThrottleMsnumberMin interval between progress events (default 100)
snapshotThrottleMsnumberMin interval between snapshot events (default 250)
workerFactory() => WorkerCustom worker factory (skips importScripts entirely)

Source spec

FieldTypeDescription
idstringUnique source identifier
role'base' | 'lookup'One base source required; lookups are joined in the worker
dataUrlstringURL to fetch the Arrow IPC stream
dataFetchInitRequestInitFetch options (method, headers, body)
arrowBufferArrayBufferPre-loaded Arrow buffer (alternative to dataUrl)
projection.renameRecord<string, string>Rename Arrow columns to internal field names
projection.transformsRecord<string, string>Type coercion: 'timestampMs', 'number', 'constantOne'

Metric spec

FieldTypeDescription
idstringKey in the result (snapshot.kpis[id], group.value[id])
fieldstringColumn to aggregate (not needed for 'count')
opstring'count', 'sum', 'avg', 'avgNonZero'

Async runtime methods

MethodReturnsDescription
runtime.readyPromise<Progress>Resolves when all data is loaded
runtime.on(event, fn)() => voidSubscribe to 'progress', 'snapshot', 'error'
runtime.query(request)Promise<{ snapshot, rows }>Apply filters, return aggregations + row page
runtime.snapshot(filters)Promise<Snapshot>Aggregations only, no row data
runtime.rows(query)Promise<RowResult>Paged row data only
runtime.updateFilters(filters)PromiseUpdate filters without reading results
runtime.append(records)Promise<number>Add rows, returns new dataset size
runtime.createGroup(spec)Promise<string>Add a group at runtime, returns its ID
runtime.disposeGroup(id)Promise<void>Remove a dynamically created group
runtime.removeFiltered(selection)Promise<number>Remove 'included' or 'excluded' rows
runtime.bounds(request)PromiseGet min/max for fields
runtime.groups(request)PromiseAd-hoc group queries
runtime.dispose()PromiseTerminate the worker

Architecture

Arrow IPC source (Cube.dev, file, etc.)
|
v HTTP streaming response
Web Worker (owns fetch, decode, crossfilter instance)
| Apache Arrow RecordBatchReader
| Incremental batch append with projection/rename/transforms
| WASM-accelerated encoded filter scans
v
Declarative filters in, snapshots + row slices out
|
v postMessage with Transferable buffers
Main thread (renders UI only)

Development

npm install
npm test# vitest + eslint
npm run build # rollup -> crossfilter.js + crossfilter.min.js
npm run benchmark

License

Apache-2.0. Based on the original crossfilter by Mike Bostock and Jason Davies.

About

Fast n-dimensional filtering and grouping of records.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages