Read Tabix-indexed files using either .tbi or .csi indexes.
npm install @gmod/tabiximport { TabixIndexedFile } from '@gmod/tabix'
// Local file — TBI index assumed at path + '.tbi'
const file = new TabixIndexedFile({ path: 'file.vcf.gz' })
// CSI index
const csi = new TabixIndexedFile({
path: 'file.vcf.gz',
csiPath: 'file.vcf.gz.csi',
})
// Remote files
const remote = new TabixIndexedFile({
url: 'https://example.com/file.vcf.gz',
tbiUrl: 'https://example.com/file.vcf.gz.tbi',
})
// Or with a filehandle from generic-filehandle2
import { RemoteFile } from 'generic-filehandle2'
const custom = new TabixIndexedFile({
filehandle: new RemoteFile('https://example.com/file.vcf.gz'),
tbiFilehandle: new RemoteFile('https://example.com/file.vcf.gz.tbi'),
})Over HTTP it is worth swapping in
@gmod/range-cache-filehandle.
A query fetches the index once, then reads the BGZF blocks it points at as byte
ranges spread through the file. Overlapping queries re-read the same blocks:
panning twenty half-overlapping windows across the 3.4 MB test BED file reads 11
MB. The cache serves those reads out of 256 KiB chunks, so neighboring blocks
share a request and each byte is fetched once.
import { RemoteFileWithRangeCache } from '@gmod/range-cache-filehandle'
const cached = new TabixIndexedFile({
filehandle: new RemoteFileWithRangeCache('https://example.com/file.vcf.gz'),
tbiFilehandle: new RemoteFileWithRangeCache(
'https://example.com/file.vcf.gz.tbi',
),
})Fetches lines overlapping a region. start/end are 0-based half-open
coordinates (unlike the tabix CLI which uses 1-based closed).
const lines: string[] = []
await file.getLines('chr1', 200, 300, line => lines.push(line))The callback also receives the virtual file offset and parsed coordinates for the line:
await file.getLines('chr1', 200, 300, (line, fileOffset, start, end) => {
lines.push(line)
})Pass an options object instead of a bare callback to abort the query or track download progress:
const aborter = new AbortController()
await file.getLines('chr1', 200, 300, {
lineCallback: (line, fileOffset, start, end) => lines.push(line),
signal: aborter.signal,
onProgress: (bytesDownloaded, totalBytes) => {
console.log(`${bytesDownloaded}/${totalBytes}`)
},
})onProgress ticks once per chunk — the run of BGZF blocks the index resolves a
query to — including instant ticks for chunks already cached, and the index
supplies totalBytes up front, which is enough for a determinate progress bar.
Notes:
- The scan skips meta/comment lines
- Line strings have no trailing whitespace
- Pass
undefinedforendto read to the end of the contig - A
refNamethat is not in the index yields no lines and no error, so achr1/1naming mismatch looks like an empty region. Check againstgetReferenceSequenceNamesif a query comes back unexpectedly empty start > endthrows aTypeError;start === endreturns without reading
<script src="https://unpkg.com/@gmod/tabix/dist/tabix-bundle.js"></script>See example/index.html for a working demo. It fetches the
VCF over HTTP, so serve the directory (e.g. npx serve example) rather than
opening the file directly.
getLines turns a region into BGZF chunks through the index and decompresses
each one in wasm — index reads included, since .tbi and .csi are bgzipped
too. The rest is ordinary JS: it matches lines as bytes and decodes only the
ones you asked for. docs/dataflow.md has the diagram and
walks it through.
The file then holds on to those decompressed chunks, so overlapping and adjacent
queries reuse them instead of inflating again — up to 1GB per file, dropped
after three idle minutes. A consumer holding one file per track should bound
them together with a shared chunkCacheBudget rather than shrinking each file's
own ceiling: docs/caching.md.
BGZF blocks inflate independently, so that decompression can spread across threads.
import { getSharedWorkerPool } from '@gmod/bgzf-filehandle'
const file = new TabixIndexedFile({
url: 'https://example.com/yourfile.vcf.gz',
// the pending promise is fine — it is awaited at the point of use
bgzfWorkerPool: getSharedWorkerPool(),
})Safe to pass unconditionally: getSharedWorkerPool() returns undefined under
node, or anywhere the host forbids Workers, which keeps the in-process path. No
cross-origin isolation needed. tabix-js never creates a pool on its own — the
thread budget belongs to the consumer.
Worth about 1.4x here, against the 1.95x a BAM reader reports. Measured in
jbrowse-components on test/data/1kg.chr1.subset.vcf.gz — 213MB of 1000
Genomes, headless Chrome, real HTTP, four workers, arms interleaved, both
returning the same record count: 1.34-1.46x across five window sizes and a
twelve-step pan.
The decompression itself moves 1.83x. What holds the end-to-end figure below that is a 28% floor of per-line byte scanning and string decoding, which no worker count reaches — and that floor is at its worst on multi-sample VCF, whose records carry a genotype field per sample and run to ~60KB a line. A format with narrower lines sits closer to BAM. If you want more than ~1.5x on a multi-sample VCF, the scan is what is left to attack, not the decompression.
Worker counts, lifecycle and benchmarks: bgzf-filehandle's worker pool docs; the end-to-end numbers above, and how to confirm a pool is really engaging in production rather than quietly falling back, are in jbrowse-components' BGZF_WORKER_POOL.md.
- docs/api.md — every constructor arg and method
- docs/dataflow.md — a query end to end, diagrammed
- docs/optimizations.md — why each step of that path looks the way it does, and what measured it
- docs/caching.md — sizing the decompressed-chunk cache, and bounding many files together
- agent-docs/adr/ — the measurements behind those decisions
- agent-docs/TODO.md — what is worth doing next, and what has to be measured before it
- CONTRIBUTING.md — development and release steps
Written with NHGRI funding as part of JBrowse. If you use this in a publication, please cite the most recent JBrowse paper at jbrowse.org.
MIT © Robert Buels