Skip to content

Fall Back to a Static Map in Maptoolkit Maps JS

An interactive map needs WebGL, and a small share of visitors do not have it: locked-down corporate browsers, very old devices, and anyone with hardware acceleration switched off. A check on its own only tells you the map will fail. Pairing it with a Static Maps API image turns that failure into a map that still shows where the thing is.

const API_KEY = 'YOUR_API_KEY';

    const VIEW = { center: [11.39085, 47.27574], zoom: 13 };
    const STYLE = 'maptoolkit-maptoolkit.summer';
    // One image for both sides, so the pin cannot differ in shape or size. It is drawn at
    // twice its display size (54x72 for a 27x36 pin), which suits factor: 2 below and stays
    // sharp on high-density screens in the interactive map too. The static endpoint fetches
    // it itself, so it has to be a public absolute URL.
    const PIN = { url: 'https://docs.maptoolkit.com/demos/maps-js-fall-back-to-a-static-map-pin.png', width: 27, height: 36 };

    // Ask for a real context. Checking for the WebGLRenderingContext constructor only
    // proves the browser knows the name, not that it can give you a working context.
    function webglSupported() {
        try {
            const canvas = document.createElement('canvas');
            const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
            if (!gl) return false;
            // A context that reports no extensions is usually a software stub.
            return gl.getSupportedExtensions() !== null;
        } catch (e) {
            return false;
        }
    }

    function staticMap(width, height) {
        const url = new URL('https://staticmap.maptoolkit.net');
        url.searchParams.set('maptype', STYLE);
        // The endpoint takes lat,lng, the opposite of the map's [lng, lat].
        url.searchParams.set('center', `${VIEW.center[1]},${VIEW.center[0]}`);
        // Static zoom counts 256 px tiles; the map counts 512 px tiles, so it is one lower.
        url.searchParams.set('zoom', VIEW.zoom + 1);
        url.searchParams.set('size', `${Math.min(Math.round(width), 1280)}x${Math.min(Math.round(height), 1280)}`);
        // `factor` doubles the rendered resolution of the map but stamps the icon at its
        // native pixel size, so a 54x72 icon on a factor:2 image displays at 27x36.
        url.searchParams.set('factor', 2);
        url.searchParams.set('marker', `icon:${PIN.url}|anchor:bottom|center:${VIEW.center[1]},${VIEW.center[0]}`);
        url.searchParams.set('api_key', API_KEY);
        return url.toString();
    }

    function renderStatic(reason) {
        const container = document.getElementById('map');
        const { width, height } = container.getBoundingClientRect();

        const img = document.createElement('img');
        img.className = 'static-fallback';
        img.src = staticMap(width, height);
        img.alt = 'Map of Innsbruck';
        container.replaceChildren(img);

        document.getElementById('note').innerHTML =
            `<b>Static map</b><br>${reason} The image needs no WebGL, loads in one request and works in email and print.`;
    }

    function renderInteractive() {
        const map = new maptoolkit.Map({
            container: 'map',
            apiKey: API_KEY,
            style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
            center: VIEW.center,
            zoom: VIEW.zoom,
            attributionControl: { compact: false }
        });

        map.addControl(new maptoolkit.NavigationControl(), 'top-right');

        const pin = document.createElement('img');
        pin.src = PIN.url;
        pin.width = PIN.width;
        pin.height = PIN.height;
        pin.alt = '';
        new maptoolkit.Marker({ element: pin, anchor: 'bottom' }).setLngLat(VIEW.center).addTo(map);

        // WebGL can also be lost after a successful start, when a driver resets or the
        // tab is backgrounded too long. The fallback covers that too.
        map.on('webglcontextlost', () => renderStatic('The graphics context was lost.'));

        document.getElementById('note').innerHTML =
            '<b>Interactive map</b><br>WebGL is available, so the vector map is rendering.' +
            '<button id="simulate">Show the fallback</button>';

        document.getElementById('simulate').addEventListener('click', () => {
            map.remove();
            renderStatic('Simulated: WebGL reported unavailable.');
        });
    }

    webglSupported() ? renderInteractive() : renderStatic('This browser reports no WebGL support.');
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Static Map Fallback - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Swap in a static map image when WebGL is unavailable." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        /* Scoped to the fallback image. A bare `#map img` also matches the SDK's own
           logo control and stretches it across the map. */
        #map > img.static-fallback { width: 100%; height: 100%; object-fit: cover; display: block; }
        #note {
            position: absolute; top: 10px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.5 system-ui, sans-serif; padding: 10px 12px; max-width: 260px;
        }
        #note button { margin-top: 8px; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="note"></div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    const VIEW = { center: [11.39085, 47.27574], zoom: 13 };
    const STYLE = 'maptoolkit-maptoolkit.summer';
    // One image for both sides, so the pin cannot differ in shape or size. It is drawn at
    // twice its display size (54x72 for a 27x36 pin), which suits factor: 2 below and stays
    // sharp on high-density screens in the interactive map too. The static endpoint fetches
    // it itself, so it has to be a public absolute URL.
    const PIN = { url: 'https://docs.maptoolkit.com/demos/maps-js-fall-back-to-a-static-map-pin.png', width: 27, height: 36 };

    // Ask for a real context. Checking for the WebGLRenderingContext constructor only
    // proves the browser knows the name, not that it can give you a working context.
    function webglSupported() {
        try {
            const canvas = document.createElement('canvas');
            const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
            if (!gl) return false;
            // A context that reports no extensions is usually a software stub.
            return gl.getSupportedExtensions() !== null;
        } catch (e) {
            return false;
        }
    }

    function staticMap(width, height) {
        const url = new URL('https://staticmap.maptoolkit.net');
        url.searchParams.set('maptype', STYLE);
        // The endpoint takes lat,lng, the opposite of the map's [lng, lat].
        url.searchParams.set('center', `${VIEW.center[1]},${VIEW.center[0]}`);
        // Static zoom counts 256 px tiles; the map counts 512 px tiles, so it is one lower.
        url.searchParams.set('zoom', VIEW.zoom + 1);
        url.searchParams.set('size', `${Math.min(Math.round(width), 1280)}x${Math.min(Math.round(height), 1280)}`);
        // `factor` doubles the rendered resolution of the map but stamps the icon at its
        // native pixel size, so a 54x72 icon on a factor:2 image displays at 27x36.
        url.searchParams.set('factor', 2);
        url.searchParams.set('marker', `icon:${PIN.url}|anchor:bottom|center:${VIEW.center[1]},${VIEW.center[0]}`);
        url.searchParams.set('api_key', API_KEY);
        return url.toString();
    }

    function renderStatic(reason) {
        const container = document.getElementById('map');
        const { width, height } = container.getBoundingClientRect();

        const img = document.createElement('img');
        img.className = 'static-fallback';
        img.src = staticMap(width, height);
        img.alt = 'Map of Innsbruck';
        container.replaceChildren(img);

        document.getElementById('note').innerHTML =
            `<b>Static map</b><br>${reason} The image needs no WebGL, loads in one request and works in email and print.`;
    }

    function renderInteractive() {
        const map = new maptoolkit.Map({
            container: 'map',
            apiKey: API_KEY,
            style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
            center: VIEW.center,
            zoom: VIEW.zoom,
            attributionControl: { compact: false }
        });

        map.addControl(new maptoolkit.NavigationControl(), 'top-right');

        const pin = document.createElement('img');
        pin.src = PIN.url;
        pin.width = PIN.width;
        pin.height = PIN.height;
        pin.alt = '';
        new maptoolkit.Marker({ element: pin, anchor: 'bottom' }).setLngLat(VIEW.center).addTo(map);

        // WebGL can also be lost after a successful start, when a driver resets or the
        // tab is backgrounded too long. The fallback covers that too.
        map.on('webglcontextlost', () => renderStatic('The graphics context was lost.'));

        document.getElementById('note').innerHTML =
            '<b>Interactive map</b><br>WebGL is available, so the vector map is rendering.' +
            '<button id="simulate">Show the fallback</button>';

        document.getElementById('simulate').addEventListener('click', () => {
            map.remove();
            renderStatic('Simulated: WebGL reported unavailable.');
        });
    }

    webglSupported() ? renderInteractive() : renderStatic('This browser reports no WebGL support.');
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create a page that checks for WebGL support and renders an interactive Maptoolkit map when available, or a Static Maps API image of the same location when not, with a button to simulate the fallback.

How it works

The check asks for a context rather than looking for the constructor. 'WebGLRenderingContext' in window is true in browsers that will still refuse to hand you a context, which is exactly the population you are trying to catch. Requesting webgl2 and falling back to webgl matches what the renderer itself does.

getSupportedExtensions() !== null catches a further case: a context object that exists but is a lost or stubbed one. It returns null there, while a working context returns an array.

The Static Maps API takes lat,lng in center, latitude first, the opposite of the [lng, lat] the map object takes. Both orders appear within a few lines here, which is why the conversion is written out rather than hidden.

Static zoom and map zoom are not the same number. The Static Maps API counts 256 pixel tiles and MapLibre counts 512 pixel tiles, so the same view is one zoom level higher on the static endpoint. Passing the map’s zoom straight through gives an image covering four times the area, which is why VIEW.zoom + 1 is in the request.

factor does not scale markers, so the icon is supplied at double size. factor multiplies the rendered resolution of the map, so factor: 2 returns an 800 pixel wide image for a 400 pixel box and the tiles come back sharp on a retina screen. The marker does not follow: it is stamped at its own pixel size into the larger canvas, so once the image is displayed at half size the marker is half size with it. With the endpoint’s default pin that makes the fallback pin visibly smaller than the interactive one.

The fix is an icon drawn at the resolution the factor implies. The pin here is a 54 by 72 PNG for a 27 by 36 pin, so on a factor=2 image it displays at exactly 27 by 36.

The size is clamped because the endpoint has a maximum and a container on a large monitor can ask for more than it allows.

The CSS selector is scoped to #map > img.static-fallback on purpose. A bare #map img also matches the SDK’s own logo control, and width: 100%; height: 100% then stretches the logo across the whole map, which is both wrong and an attribution problem.

Both sides draw the same image, so the pin matches in shape, size and position. The Static Maps API gets it as icon: in the marker parameter, and the SDK gets it as the element of its Marker. The endpoint’s default pin and the SDK’s default Marker are different shapes, so recolouring one to match the other still leaves a visible jump when the fallback takes over. anchor: 'bottom' on the marker and anchor:bottom in the marker parameter put the pin’s tip on the coordinate on both sides; the PNG has no shadow below the tip, so its bottom edge is the tip.

The icon has to be a real PNG at a public URL, because the static endpoint fetches and renders it server side. A data URI or a localhost path will not work there.

map.remove() before swapping in the image matters. Leaving the map instance alive keeps its WebGL context, its event listeners and its tile requests running behind an image nobody can see.

webglcontextlost is the case people forget. A context can go away after a successful start, when a graphics driver resets or a tab has been backgrounded for a long time, and the map then goes blank with no error in the console. Wiring the same fallback to that event costs one line.

The static path is worth treating as a first-class option rather than a last resort. It is also the right answer for a print stylesheet, an email, a PDF export, an open graph preview image, and any thumbnail where a full renderer is more cost than the page can justify.

Next steps

The other reason to reach for an image is performance rather than capability. A list of twenty search results with a small map each should be twenty images, not twenty WebGL contexts, since browsers cap how many exist at once and silently discard the oldest.

Measuring frame rate and tile timings tells you whether a slow device would be better served the static path anyway, and the Static Maps API covers the markers, paths and bounding boxes that make the image more than a plain basemap.