Cache Vector Tiles Using Service Workers

Published 7/24/2016

Using service workers it is possible to cache vector tiles to eliminate repetitive requests to the tile server while also ensuring usability of maps in an environment with little or no connectivity.



Web APIs


Service Worker




var tileDomain = '';

function unableToResolve() {
   return new Response('', {status: 503, statusText: 'service unavailable'});

self.addEventListener('fetch', function(event) {
   var request = event.request;
   // if the cached response does not exist perform a fetch for that resource
   // and return the response, otherwise return response from cache
   var queriedCache = function(cached) {
      var response = cached || fetch(request)
         .then(fetchedFromNetwork, unableToResolve)

      return response;

   var fetchedFromNetwork = function(response) {
      // cache response if request was successful
      if (response.status === 200) { {
            // store response in cache keyed by original request
            cache.put(event.request, response);
      // cache.put consumes response body
      return response.clone();

   // only cache requests from tile server
   if (request.url.indexOf(tileDomain) === -1) {
   } else {

Cache Vector Tiles Based on Geolocation

When a position is determined from the Geolocation API then more requests will be made for additional vector tiles from the tile service in order to build out the cache for deeper zoom levels near the current position. Precaching these tiles should allow for more fluid zoom behavior in environments with limited connectivity.

Sending a new XMLHttpRequest to the tile server will trigger the service worker to cache new vector tiles.

var tileSrc = '{z}/{x}/{y}.topojson?api_key={api_key}';
var zoomRangeCache = [10, 11, 12, 13, 14, 15];
var defaultZoom = 12;
var defaultCenter = [-98.5795, 39.828175];
var cacheRadius = 10000;
var tilesCached = false;
var map = new TileMap({
   center: defaultCenter,
   layers: ['water', 'landuse', 'roads', 'buildings'],
   selector: '.map',
   url: tileSrc,
   zoom: defaultZoom

// make requests for tiles given a set of coordinates and zoom ranges
// these requests will trigger the service worker to cache responses
function requestTiles(src, imageCoordinates, zoomRange) {
   zoomRange.forEach(function(zoom) {
      imageCoordinates.forEach(function(coordinate) {
         var request = new XMLHttpRequest();
         var tileSrc = src;
         tileSrc = tileSrc.replace('{z}', zoom);
         tileSrc = tileSrc.replace('{x}', coordinate[0]);
         tileSrc = tileSrc.replace('{y}', coordinate[1]);
'GET', tileSrc);

navigator.geolocation.watchPosition(function(position) {
   // cache nearby vector tiles if accuracy is within 10km
   if (!tilesCached && position.coords.accuracy <= cacheRadius) {
      requestTiles(tileSrc,, zoomRangeCache);
      tilesCached = true;

Test Results

Requests without a service worker registered.

Requests with service worker responding with cached responses.

Further Improvement

The cache control strategy utilized in this demo could be improved to request updated tiles from the server when cached responses exceed a maximum age. Currently the service worker will not update the cached response after the initial request.

The initial reference implementation renders the map as an svg element with tens of thousands of path elements which causes janky rendering, panning/zooming on mobile. A better solution would make use of canvas to render paths from D3. One drawback to this approach is losing the ability to directly style the map using CSS.

Further Reading