Getting the Most Out of Mapbox GL JS Performance
A practical guide to understanding what Mapbox GL JS v3 is actually doing, measuring interaction lag properly, and eliminating the style patterns that make maps feel slow.
A different kind of performance problem
Most web performance work centres on load time. You optimise your bundle, defer your fonts, add a CDN, and the Lighthouse score improves. Mapbox GL JS maps are mostly indifferent to all of that. A map can pass every Core Web Vitals threshold comfortably and still feel like dragging sand across the screen during a pan, because the performance that matters is not how quickly the page loads but how quickly the map can redraw on every animation frame for the entire duration of an interaction. That is a GPU and frame-budget problem, and the diagnostic tools are completely different.
This article targets version 3 of Mapbox GL JS. The broad patterns hold for earlier releases, but some internals changed significantly between v1 and v3, particularly around the lighting system and the Standard style, so a few specifics may differ if you are on an older version.
What Mapbox GL JS is actually doing
Mapbox GL JS uses WebGL directly. There is no intermediate graphics library between your style configuration and the GPU (no Three.js, no Babylon.js, no abstraction layer of any kind). Mapbox wrote their own WebGL rendering engine specifically for cartographic work. Every geometric feature on the map, every road segment, building footprint and land polygon, is ultimately converted into triangles, because that is what WebGL operates on. The resulting geometry is uploaded to the GPU and redrawn on every animation frame, which is why map performance lives or dies by how much work you are asking the GPU to do each time.
Tile parsing happens off the main thread, on a Web Worker, so downloading new tiles as you pan does not stall your interactions. You do not configure this; it happens automatically.
The rendering loop runs on the main thread, driven by requestAnimationFrame. On each frame, the Painter, which is Mapbox's internal render orchestrator, iterates through every layer in your style and issues WebGL draw calls in three ordered passes: an offscreen pass for pre-rendered content, an opaque pass for solid layers, and a translucent pass for everything else, which runs last because transparency requires correct depth ordering. The entire sequence must complete in under 16.67 milliseconds to sustain 60 frames per second. When something in your style pushes frame time past that threshold, the interaction feels sluggish, and it will continue to feel sluggish until something in the pipeline is made cheaper.
Measuring the lag properly
requestAnimationFrame timing is the right tool for diagnosing map interaction lag, not Lighthouse and not the browser's network tab. Mapbox fires a render event every time it redraws the canvas. Listening to that event alongside performance.now() gives you frame-level timing: how long each redraw is taking, what your rolling average frame rate is, and whether slowdowns are consistent or occasional spikes. The following implementation attaches a live overlay to any map container and updates it on every render cycle:
function attachFpsOverlay(map, container) {
const overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute;top:8px;left:8px;background:rgba(0,0,0,0.7);' +
'color:#fff;font:12px monospace;padding:6px 10px;border-radius:4px;' +
'pointer-events:none;z-index:999';
container.style.position = 'relative';
container.appendChild(overlay);
const frameTimes = [];
let lastTime = performance.now();
map.on('render', () => {
const now = performance.now();
frameTimes.push(now - lastTime);
lastTime = now;
if (frameTimes.length > 60) frameTimes.shift();
const avg = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
const fps = Math.round(1000 / avg);
const ms = avg.toFixed(1);
overlay.textContent = `${fps} fps ${ms}ms/frame`;
});
}The rolling 60-frame average smooths out occasional garbage-collection spikes that would otherwise make the readout jump unpredictably. Once the overlay is attached, the effect of any style change is directly visible: you can watch frame time drop in real time as you consolidate layers or adjust expressions, which turns performance tuning from guesswork into measurement.
For a more reproducible approach when comparing across devices or browser versions, the render event can be used to record a histogram of frame times across a scripted interaction: pan a fixed number of pixels, zoom in two levels, zoom out again, then compare median and 95th-percentile frame times before and after a change. Those numbers are concrete enough to put in a pull request or hand to a colleague on a different machine.
What Mapbox already does for you
Before reaching for manual optimisations, it is useful to know what the library already handles, because several things that might seem like obvious interventions are already solved. Tile caching is automatic: downloaded tiles are held in a least-recently-used cache and reused across pan operations without refetching. The configurable maxTileCacheSize option lets you tune how much memory the cache occupies on constrained devices. Worker-thread parsing keeps tile processing off the main thread. The three-pass rendering pipeline batches opaque geometry separately from transparent layers, which reduces the blending overhead that would otherwise accumulate with depth ordering.
Three debug flags are genuinely useful during development and almost universally unknown. Setting map.showTileBoundaries = true draws an outline around each tile and prints the tile coordinates and uncompressed source size in the corner, which helps diagnose over-fetching or understand which tiles cover the current viewport. Setting map.showCollisionBoxes = true renders bounding boxes around every symbol candidate: green for labels that survived placement, red for those culled by the collision algorithm. If you have a dense symbol layer and see a large proportion of red boxes, you are paying CPU cost every frame for labels that will never appear. Setting map.showOverdrawInspector = true colour-codes the canvas by overdraw intensity, where white fragments have been shaded eight or more times and black fragments not at all, which makes it immediately apparent when your layer structure is doing redundant blending work.
Text rendering: Signed Distance Fields
Text on a Mapbox map is not rendered the way text is rendered in the browser — there is no CSS font pipeline, no canvas fillText, no SVG. Mapbox uses a technique called Signed Distance Fields (SDF) to store glyphs as pre-computed font textures that scale cleanly to any size and rotate at any angle without losing quality. A single glyph texture works at 12px or 96px, handles halos and outlines without extra data, and stays sharp through every zoom level. For a map renderer displaying labels at dozens of sizes simultaneously, this is a meaningful saving. From your perspective as a developer, it mostly just works well — the part worth understanding is what runs alongside it.
The expensive part of symbol layers is not the text rendering itself but the collision detection that accompanies it.
The expensive part of symbol layers is not the SDF rendering itself but the collision detection that accompanies it. Every time the map redraws during an interaction, Mapbox runs a placement algorithm that checks every candidate label against a spatial index to determine which labels are visible and which are hidden because they would overlap a higher-priority neighbour. On a style with many symbol layers, or with large numbers of densely packed features competing for label placement, this CPU cost accumulates and begins eating into the frame budget. The debug flag map.showCollisionBoxes makes this concrete: a large proportion of red boxes indicates that you are evaluating significantly more label candidates per frame than will ever be shown.
The practical optimisations follow from this: keep symbol layer count low, set minzoom and maxzoom on each symbol layer so the collision detection only runs at zoom levels where the labels are relevant, and avoid text-allow-overlap: true unless you have a specific reason for it, because it disables the culling that would otherwise reduce the per-frame work.
Rasterisation and tile types
It is worth understanding the difference between the two tile formats Mapbox supports, because the choice has performance implications. Vector tiles contain the raw geometry of features — road lines, building outlines, land polygons — compressed and served per zoom level. The client receives that geometry and draws it fresh every frame at whatever zoom, rotation, and style you have configured. This is why a vector map stays sharp as you zoom in and why you can restyle features dynamically on the client. The drawing work happens on your user's device, which is where the frame budget constraint comes from.
Raster tiles work quite differently: they are pre-rendered images, typically JPEG or PNG, that the client simply displays as-is. There is no geometry to draw and no client-side processing, which makes them predictably cheap to render. The trade-off is that they pixelate when you zoom past their native resolution and cannot be restyled on the client at all — what you get from the server is what you show. Satellite imagery and aerial photography are the natural home for raster tiles; any layer you want to filter, query, or style dynamically should be a vector source. For your own data, the choice between the two also intersects with dataset size, which we come to shortly.
The layer problem
This is the one that tends to catch people off guard, and it caught us off guard on a project that was otherwise going rather smoothly. A map with more than 100 layers can feel genuinely terrible to interact with on hardware that handles everything else at 60 frames per second without complaint. The reason is not any one expensive operation but the accumulation of many small ones: for every layer in your style, the Painter performs at least one WebGL draw call per visible tile, evaluates your filter expression, retrieves the appropriate Bucket, configures the shader program, sets uniform values, and updates render state. None of this is slow in isolation. Across 100 layers and 20 visible tiles, it adds up to a significant slice of the 16.67ms frame budget before any geometry has actually been drawn.
The pattern that most reliably produces this problem is using a separate layer for each logical category in your data. A points-of-interest map might have a restaurant layer, a café layer, a museum layer, and so on, each with a filter expression selecting features of the right type from a shared source. This is an intuitive way to model the data, and Mapbox Studio makes it easy to work this way. With ten categories it is entirely fine. With a hundred, it becomes the dominant cause of frame lag.
The fix is data-driven styling: one layer whose paint properties are driven by expressions that read feature attributes, rather than one layer per attribute value. The visual result is identical. The rendering cost is not.
// One layer per category — avoid at scale
const CATEGORY_COLOURS = {
restaurant: '#e63946',
cafe: '#457b9d',
museum: '#2a9d8f',
// ... 97 more entries
};
for (const [category, colour] of Object.entries(CATEGORY_COLOURS)) {
map.addLayer({
id: `poi-${category}`,
type: 'circle',
source: 'points-of-interest',
'source-layer': 'poi',
filter: ['==', ['get', 'category'], category],
paint: {
'circle-color': colour,
'circle-radius': 6,
},
});
}// One layer, data-driven — same visual result
map.addLayer({
id: 'poi',
type: 'circle',
source: 'points-of-interest',
'source-layer': 'poi',
paint: {
'circle-color': [
'match',
['get', 'category'],
'restaurant', '#e63946',
'cafe', '#457b9d',
'museum', '#2a9d8f',
'park', '#52b788',
'#aaaaaa',
],
'circle-radius': 6,
},
});The second version issues one draw call per tile instead of one per category per tile. At 20 visible tiles and 100 categories, that is the difference between 2,000 draw calls and 20. The match expression is evaluated per feature on the GPU inside the fragment shader, which is exactly where that kind of branching belongs. GPUs execute conditional logic across thousands of fragments in parallel, at a cost that is negligible compared to the CPU overhead of 100 separate layer evaluations.
For interactive states, hover highlights, selection indicators, anything that changes per feature without changing the underlying data, use setFeatureState rather than a separate layer or a filter swap. setFeatureState marks individual features with arbitrary key-value pairs that can be read inside paint expressions, without re-parsing or re-uploading any geometry. One prerequisite: if your source is GeoJSON, you need to set generateId: true on the source definition. Without it, Mapbox does not assign the stable numeric IDs that setFeatureState requires, and the calls will silently have no effect.
map.addSource('poi', {
type: 'geojson',
data: '/api/poi.geojson',
generateId: true, // required — without this, setFeatureState silently does nothing
});
map.addLayer({
id: 'poi',
type: 'circle',
source: 'poi',
paint: {
'circle-color': [
'case',
['boolean', ['feature-state', 'hovered'], false],
'#ff6b6b',
'#457b9d',
],
'circle-radius': [
'case',
['boolean', ['feature-state', 'hovered'], false],
10,
6,
],
},
});
let hoveredId = null;
map.on('mousemove', 'poi', (e) => {
if (hoveredId !== null) {
map.setFeatureState({ source: 'poi', id: hoveredId }, { hovered: false });
}
hoveredId = e.features[0].id;
map.setFeatureState({ source: 'poi', id: hoveredId }, { hovered: true });
});
map.on('mouseleave', 'poi', () => {
if (hoveredId !== null) {
map.setFeatureState({ source: 'poi', id: hoveredId }, { hovered: false });
}
hoveredId = null;
});GeoJSON vs vector tiles for large datasets
Mapbox supports two main ways to supply your own data to the map: GeoJSON loaded directly in the browser, or vector tiles served from a URL. For small datasets, a few hundred features, GeoJSON is entirely convenient. For anything larger, the distinction starts to matter for a reason that is easy to overlook.
When you pass a large GeoJSON object to map.addSource, Mapbox processes it on a worker thread. The catch is that transferring data from your JavaScript context to the worker requires serialising the entire object, and for a dataset with tens of thousands of features that serialisation, plus the subsequent parsing, can take hundreds of milliseconds and cause a visible stall on initial load. The download of a large GeoJSON file compounds this further. Vector tiles avoid both problems: they are served in small geographic chunks at the appropriate resolution for each zoom level, so the browser only downloads the tiles that cover the current viewport and refetches at higher detail as the user zooms in. For datasets above a few thousand features, converting to vector tiles using a tool like tippecanoe to produce MBTiles and serving them with something like tileserver-gl gives you substantially better load performance and a map that responds well at all zoom levels, at the cost of a backend serving step.
The silent killers
Two Map initialisation options cause disproportionate performance damage and are easy to overlook in the documentation. The first is preserveDrawingBuffer, which defaults to false. Setting it to true is required if you need map.getCanvas().toDataURL() to generate a static image of the map, which is a legitimate need for export or print features. The problem is that enabling it prevents the WebGL context from discarding the framebuffer between renders, removing an optimisation that the GPU uses to manage buffer memory across frames. The practical effect is a meaningful drop in rendering throughput. If you need screenshot capability, the cleaner approach is to initialise a separate off-screen map instance with preserveDrawingBuffer: true specifically for snapshot generation, leaving your interactive map unaffected.
The second is devicePixelRatio. Mapbox GL JS uses window.devicePixelRatio by default, which on a Retina display is 2, meaning the WebGL canvas is rendered at twice the element's CSS dimensions, or four times the pixel count. On desktop hardware with a capable GPU this is rarely an issue. On mid-range mobile devices, particularly older Android phones, it is a significant GPU load multiplier. Capping the ratio at initialisation is a one-line change with a meaningful impact on GPU-limited hardware:
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/standard',
accessToken: process.env.NEXT_PUBLIC_MAPBOX_TOKEN,
// rendering above 2x gives no visible benefit and doubles the pixel count again
pixelRatio: Math.min(window.devicePixelRatio, 2),
});A note on Safari
If you have tested a Mapbox map on an older version of Safari and found performance dramatically worse than on Chrome or Firefox on identical hardware, not marginally worse but visibly, substantially worse, you are not alone. Older versions of Safari had notably poor WebGL performance under certain conditions, with frame times that in some cases suggested the GPU was not being used effectively. Whether this reflected a WebGL implementation gap or something in how older Safari handled GPU process isolation, the practical effect was the same: a map running at 60 frames per second in Chrome might drop to something considerably lower in Safari on the same machine. The gap has narrowed considerably in recent releases, but if your users include people on Safari 15 or earlier, it is worth testing explicitly on that target rather than inferring from Chrome performance. The frame rate overlay described above gives you a concrete number to track across browser versions.
Running the demo
To make the layer consolidation pattern tangible, we have put together a self-contained demo that runs both the naive multi-layer approach and the data-driven single-layer approach side by side, with the FPS overlay active on each map so the difference is directly visible during interaction. The demo requires a Mapbox public access token. You will need to bring your own.
git clone https://github.com/techrelays/mapbox-performance-demo
cd mapbox-performance-demo
npm install
# Add your Mapbox public token to .env.local
echo "NEXT_PUBLIC_MAPBOX_TOKEN=pk.your_token_here" > .env.local
npm run devThe demo generates 5,000 points distributed across a fixed geographic area, each assigned one of 100 categories. The naive version registers 100 circle layers with individual filter expressions. The optimised version uses one layer with a match expression over the same source. Pan quickly across a dense cluster to feel the difference most clearly, particularly if you enable the symbol layer option, which adds collision detection overhead to the comparison and makes the gap between the two approaches more pronounced.
Ready to build something?
Tell us about your project and we'll come back to you within 24 hours.
Let's Talk →