Stop restyling Mapbox layers on every zoom: use GL JS expressions
Move zoom- and data-driven styling out of JavaScript event handlers and into declarative Mapbox GL JS style expressions.
04 Apr 2026, 03:17 UTC

Stop restyling Mapbox layers on every zoom
You open the browser console and see map.setPaintProperty firing inside zoom and move handlers. One listener rescales circle radius, another rewrites colors from a feature property, and a third toggles visibility. The map works, but presentation logic is scattered across imperative JavaScript, and the main thread repeats the same math every frame.
The useful takeaway: move those rules into the layer’s paint properties using Mapbox GL JS style expressions. Expressions are JSON arrays evaluated by the map engine natively, so a single declarative layer can respond to zoom level and feature properties without a JavaScript loop on every interaction.
What a style expression is
A Mapbox GL JS style expression is a JSON array whose first element is an expression name, such as interpolate, zoom, or get. The remaining elements are arguments. For example, ['get', 'population'] reads the population property from a feature, while ['zoom'] reads the current map zoom. The map engine applies the result directly to paint properties like circle-radius or circle-color.
Because expressions are data, not executable JavaScript, they cannot run arbitrary code. That limits what you can do inside a style rule, but it also means the engine can optimize evaluation and the style remains serializable. You still write ordinary JavaScript to create the map and add layers; you simply stop using event handlers to restyle them.
Worked example: style cities by population and zoom
Save the following as an HTML file and open it in a browser, or serve it from a local development server. No special file-system permissions are required beyond loading the page. Replace the placeholders with your Mapbox access token, style URL, and GeoJSON source. GeoJSON is a JSON format for representing geographic features and their properties. A client-side access token is visible in the page source, so restrict it in the Mapbox dashboard if you move beyond local development.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Mapbox expressions example</title>
<script src="MAPBOX_GL_JS_URL"></script>
<link href="MAPBOX_GL_CSS_URL" rel="stylesheet" />
<style>body { margin: 0; } #map { height: 100vh; }</style>
</head>
<body>
<div id="map"></div>
<script>
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'YOUR_MAPBOX_STYLE_URL',
center: [-98.5795, 39.8283],
zoom: 3
});
map.on('load', () => {
map.addSource('cities', {
type: 'geojson',
data: 'YOUR_GEOJSON_URL'
});
map.addLayer({
id: 'city-points',
type: 'circle',
source: 'cities',
paint: {
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
3, 4,
9, 18
],
'circle-color': [
'interpolate',
['linear'],
['get', 'population'],
0, '#fee5d9',
100000, '#fcbba1',
1000000, '#fc9272',
5000000, '#de2d26'
],
'circle-opacity': 0.8
}
});
});
</script>
</body>
</html>
The circle-radius expression interpolates between radius 4 at zoom 3 and radius 18 at zoom 9. The circle-color expression maps the population property to a color ramp. As you zoom, the engine rescales circles automatically; as you pan across features with different populations, colors update per feature. No map.on('zoom', ...) handler is needed for these visual rules.
Verify the expression is driving the render
- Open the browser console after the map loads.
- Run
map.getPaintProperty('city-points', 'circle-radius'). The returned value should match the expression array in your layer definition, not a plain number. - Temporarily replace the expression with a constant such as
10, refresh, and confirm the circles no longer change size with zoom. - Restore the expression and confirm smooth resizing returns.
If the getter returns a constant when you expect an expression, the layer may not have loaded or the style may have been overwritten elsewhere in your code. Treat this as a diagnostic step, not a production assertion.
Guard against malformed data
Expressions assume the input exists and has the expected type. If population is missing or null on some features, interpolation can produce unexpected results. Guard the input with coalesce:
'circle-color': [
'interpolate',
['linear'],
['coalesce', ['get', 'population'], 0],
0, '#fee5d9',
100000, '#fcbba1',
1000000, '#fc9272',
5000000, '#de2d26'
]
Feature IDs and property names must match the source schema exactly. GeoJSON and vector tile sources expose properties differently, so confirm the property name in your data before referencing it in get.
Limitations and when to keep JavaScript
- Expressions cannot execute arbitrary JavaScript. Complex interactions, asynchronous data transforms, and non-visual logic still belong in application code.
- Expression support has evolved across Mapbox GL JS releases. Verify the functions and data types you use against the documentation for the version you deploy.
- Debugging a declarative expression can be harder than stepping through an imperative handler. Use
getPaintPropertyand the temporary-constant test above to isolate problems.
Closing
Audit your map for setPaintProperty calls inside move or zoom handlers. Pick one layer, move its visual rules into a paint-property expression, and verify with getPaintProperty and a zoom test. Keep imperative JavaScript for interactions that expressions cannot express, and let the map engine handle the presentation.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.