Whole-slide image · measured in your browser

No conversion needed.

Pan and zoom a 2.12 GB original Aperio SVS. PureJsImage reads its existing JPEG tiles with HTTP Range. No tile server, converted copy, or sidecar index is involved.

Original SVSStatic object storage0 runtime dependenciesWorker decode
Verified sample slides

These are unmodified public Aperio slides from the ImageBox3 Google Cloud test bucket. Each URL was verified for byte ranges, wildcard CORS, exposed Content-Range, Aperio metadata, and native tile decode. No PureJsImage proxy is involved.

Original pyramid tiles

Whole-slide viewport

Drag to pan · wheel or pinch to zoom
Waiting for slide metadata…
Opening whole-slide imageStarting the worker and connecting to object storage…

Starting the worker…

Tile requestsPending Decoded Cancelled

Drag quickly: offscreen work turns red as its AbortController is cancelled instead of piling up.

The access pattern

The browser asks for TIFF tiles, not another format.

The worker owns the range source, TIFF directory, JPEG decode, tile cancellation, and source-byte counters. The main thread owns only interaction, a bounded 192-tile bitmap LRU, and canvas drawing.

1HTTP RangeRead TIFF metadata and only requested byte spans
2Worker decodeComposite released PixelBlocks into one native tile
3Canvas + LRUDraw cached coarser tiles beneath incoming detail

Measured vs cited

What “no conversion” changes

No synthetic timings and no claim that PureJsImage is faster than Viv, OpenSlide, or libvips. This comparison is only about the path from an existing slide in object storage to a browser viewport.

QuestionThis demoConvert-to-OME-Zarr path
Source fileOriginal SVS, unmodified MeasuredA converted copy is required
Storage for serving2.12 GB as-is MeasuredAbout an order of magnitude larger for lossy-JPEG WSI Cited · OME NGFF ↗
PreprocessingNone Measuredbioformats2raw + raw2ometiff per slide; timing not measured here
ServingStatic object storage + HTTP Range MeasuredStatic serving is possible after conversion; conversion infrastructure is still required
Sidecar indexNone MeasuredNot required by Zarr; some OME-TIFF random-access workflows generate an IFD index
Bytes to browseSee the live counter above MeasuredNot measured here
Demo runtime dependencies0 MeasuredVaries
OME-Zarr is often the right call.

It is an open, actively developed format with broad tool support, standardized metadata, and independently accessible chunks. This demo is for a different moment: an Aperio slide is already sitting in a bucket and you want to inspect it before building a conversion pipeline.

Technical demonstration only. This viewer is not a diagnostic tool and has not been validated for clinical use.

Build it with PureJsImage

Decode only the native tiles in view.

The worker opens the original SVS through HttpRangeSource, reads its TIFF pyramid with openAperioSvs(), and decodes only the tile coordinates selected by the canvas viewport. Every returned block is released after it is copied into one transferable bitmap.

wsi-worker.ts
import type { PixelBlock } from 'purejsimage'
import { defaultAperioSvsLimits, openAperioSvs } from 'purejsimage/pathology'
import { HttpRangeSource } from 'purejsimage/sources/http-range'
import { openTiffDocument } from 'purejsimage/tiff'

// Open only the remote byte ranges needed for metadata and visible tiles.
const source = await HttpRangeSource.open(slideUrl, {
  blockBytes: 65_536,
  maxCacheBytes: 1_048_576,
  openSignal: signal,
})
const tiff = await openTiffDocument(source, {
  maxInputBytes: defaultAperioSvsLimits.maxSourceBytes,
  maxWidth: defaultAperioSvsLimits.maxWidth,
  maxHeight: defaultAperioSvsLimits.maxHeight,
  maxDecodedBytes: defaultAperioSvsLimits.maxRegionDecodedBytes,
  signal,
})
const slide = await openAperioSvs(tiff, {
  limits: defaultAperioSvsLimits,
  signal,
})

const copyRgbBlock = (
  target: Uint8ClampedArray,
  targetWidth: number,
  block: PixelBlock,
): void => {
  if (block.format !== 'rgb8') throw new Error('Expected an RGB8 tile')
  for (let row = 0; row < block.height; row += 1) {
    let input = row * block.stride
    let output = ((block.y + row) * targetWidth + block.x) * 4
    for (let column = 0; column < block.width; column += 1) {
      target[output] = block.data[input] ?? 0
      target[output + 1] = block.data[input + 1] ?? 0
      target[output + 2] = block.data[input + 2] ?? 0
      target[output + 3] = 255
      input += 3
      output += 4
    }
  }
}

// level, column, and row come from the current canvas viewport.
const level = slide.levels[levelIndex]
if (!level?.tileWidth || !level.tileHeight) throw new Error('Level is not tiled')
const width = Math.min(level.tileWidth, level.width - column * level.tileWidth)
const height = Math.min(level.tileHeight, level.height - row * level.tileHeight)
const rgba = new Uint8ClampedArray(width * height * 4)

for await (const block of level.tile(column, row, { signal })) {
  try {
    copyRgbBlock(rgba, width, block)
  } finally {
    block.release?.()
  }
}

const bitmap = await createImageBitmap(new ImageData(rgba, width, height))
postMessage({ level: levelIndex, column, row, bitmap }, [bitmap])