Skip to main content
Parsing a GeoPackage and rendering tiles are CPU-intensive operations. Running them on the main browser thread blocks UI interactions. Moving this work to a Web Worker keeps your page responsive.

How it works

  1. The main thread creates a Worker and sends messages to it.
  2. The worker loads the GeoPackage browser bundle via importScripts, opens the .gpkg file, and handles requests.
  3. Results (tile ImageData, GeoJSON feature collections) are posted back to the main thread via postMessage.
ImageData objects can be transferred rather than copied, which makes passing tile pixel data between the worker and main thread very efficient.

Setup

You need two files: worker.js (runs inside the worker) and index.js (runs on the main thread).
// Import the GeoPackage browser bundle inside the worker
self.importScripts('/path/to/geopackage.min.js');

const {
  GeoPackageManager,
  Canvas,
  TileUtils,
  GeoPackageTileRetriever,
  FeatureTiles,
  BoundingBox,
  setSqljsWasmLocateFile,
} = GeoPackage;

// Specify the location of the sql-wasm.wasm file
setSqljsWasmLocateFile(file => 'public/' + file);

// Message listener
onmessage = function (e) {

  // Open a GeoPackage connection from a Uint8Array
  if (e.data.type === 'load') {
    GeoPackageManager.open(e.data.file).then(gp => {
      self.gp = gp;
    });

  // Close the connection
  } else if (e.data.type === 'close') {
    self.gp.close();

  // Render a feature table tile
  } else if (e.data.type === 'get-feature-tile') {
    const table  = e.data.table;
    const x      = parseInt(e.data.x);
    const y      = parseInt(e.data.y);
    const z      = parseInt(e.data.z);
    const width  = parseInt(e.data.width);
    const height = parseInt(e.data.height);

    const featureDao = self.gp.getFeatureDao(table);
    const ft = new FeatureTiles(self.gp, featureDao, width, height);

    ft.drawTile(x, y, z).then(tile => {
      postMessage({
        type: 'tile',
        tileImageData: tile.getImageData(),
      });
    });

  // Retrieve an XYZ tile from a tile table
  } else if (e.data.type === 'get-tile') {
    const table  = e.data.table;
    const x      = parseInt(e.data.x);
    const y      = parseInt(e.data.y);
    const z      = parseInt(e.data.z);
    const width  = parseInt(e.data.width);
    const height = parseInt(e.data.height);

    self.gp.xyzTile(table, x, y, z, width, height).then(gpTile => {
      gpTile.getGeoPackageImage().then(gpImage => {
        postMessage({
          type: 'tile',
          tileImageData: gpImage.getImageData(),
        });
      });
    });

  // Return all features from a table as GeoJSON
  } else if (e.data.type === 'get-geojson') {
    const table = e.data.table;
    const features = [];
    const featureResultSet = self.gp.queryForGeoJSONFeatures(table);

    for (const feature of featureResultSet) {
      features.push(feature);
    }
    featureResultSet.close();

    postMessage({
      type: 'geojson',
      geojson: {
        type: 'FeatureCollection',
        features,
      },
    });
  }
};

Message protocol reference

typeDirectionPayloadDescription
loadmain → workerfile: Uint8ArrayOpen a GeoPackage from byte array
closemain → workerClose the GeoPackage connection
get-tilemain → workertable, x, y, z, width, heightRequest an XYZ raster tile
get-feature-tilemain → workertable, x, y, z, width, heightRender a feature table tile
get-geojsonmain → workertableReturn all features as GeoJSON
tileworker → maintileImageData: ImageDataRendered tile pixel data
geojsonworker → maingeojson: FeatureCollectionGeoJSON feature collection
The worker example above does not queue messages — if a second load message arrives before the first resolves, the self.gp reference will be overwritten. Add a loading flag or message queue for production use.

Build docs developers (and LLMs) love