This article is based on “Life of a Tile” from maplibre/maplibre-gl-js. Source links below point to Mapbox GL JS v1.13.2.
The lifecycle of a tile in Mapbox can be divided into three parts:
- Event loop: responds to user interaction and updates internal map state such as the viewport and camera.
- Tile loading: asynchronously requests the tiles, images, glyphs, and other data required by the current view.
- Render loop: draws the current map state to the screen.
Ideally, the event and render loops run at 60 frames per second, while expensive work such as tile loading runs asynchronously in Web Workers.
Event Loop
Transform
Transform stores the current view state, including pitch, zoom, bearing, and bounds. Two parts of the code update it directly:
- Methods such as Camera#panTo and Camera#setCenter on Camera, the parent class of Map.
- HandlerManager, which receives DOM events and forwards them to interaction handlers under src/ui/handler. Their changes are combined into a HandlerResult and trigger rendering. HandlerInertia adds inertial behavior after gestures such as a fast drag.
Camera and HandlerManager
After changing transform, both Camera and HandlerManager can emit events such as move, zoom, movestart, and moveend. These events—along with style changes, completed data loads, and others—schedule Map#_render() to draw a frame.
Tile Loading
Map#_render() follows two paths depending on map._sourcesDirty. When it is true, _render() first asks every source whether new data is needed. The false case is covered in the render-loop section.
Tile Selection and Requests
Every source has a corresponding SourceCache. Calling SourceCache#update(transform) determines the tile IDs that should cover the viewport and requests any missing tiles. If the exact tile is unavailable, Mapbox uses a lower-zoom tile covering the same region as a fallback.
Loading and Parsing
Missing tiles are loaded through Source#loadTile(tile, callback). Each source type follows a different path.
RasterTileSource
src/util/ajax issues a getImage request and maintains a queue that limits concurrent requests.
RasterDEMTileSource
Like the raster source, this first requests image data and then sends loadDEMTile to a worker. Reading pixels requires drawing the image to a canvas, an expensive step performed in the worker when OffscreenCanvas is available and on the main thread otherwise.
Inside the worker, RasterDEMTileWorkerSource#loadTile loads raw RGB values into DEMData and fills a one-pixel border to prevent edge flicker. The result is then returned to the main thread.
VectorTileSource
The main thread sends loadTile or reloadTile to a worker. Worker#loadTile receives it and delegates to VectorTileWorkerSource#loadTile.
VectorTileWorkerSource#loadTile then:
- Fetches binary data with ajax#getArrayBuffer().
- Decodes Protocol Buffers with pbf.
- Parses vector-tile data with @mapbox/vector-tile#VectorTile.
- Passes the result to a new WorkerTile.
The worker calls WorkerTile#parse() and processes the result for the tile ID:
- For every source layer in the vector tile, and every visible style layer using it:
recalculateLayersevaluates layout properties.style.createBucketcreates the appropriate bucket type. Bucket implementations live under src/data/bucket/* and share the Bucket base class.- Bucket#populate() consumes the source-layer features and prepares per-frame data for GPU upload, such as buffers containing triangulated geometry.
- Most layer types are now triangulated, but some require resources prepared on the main thread:
- Font PBFs requested through
getGlyphs.- GlyphManager manages the global glyph cache. For a missing character, it either draws the glyph to a canvas with tiny-sdf or requests the corresponding glyph PBF.
- Icons and patterns requested through
getImages({type: 'icon' | 'pattern'}).- ImageManager manages these caches and requests missing images over the network.
- Font PBFs requested through
When WorkerTile#maybePrepare() determines that all resources are ready, potpack packs glyphs, icons, and patterns into square atlases suitable for GPU upload. These are stored in GlyphAtlas and ImageAtlas. For each waiting layer, StyleLayer#recalculate() then runs, followed by:
addFeatureson buckets waiting for pattern resources.- performSymbolLayout() on symbol buckets. It computes layout properties at the current zoom, positions symbols according to glyph shapes, stores triangulated symbol geometry, and calculates collision boxes for text and icon placement.
The buckets, feature index, collision boxes, glyph atlas, and image atlas are returned to the main thread.
GeojsonSource
This path is almost identical to VectorTileSource: it sends loadTile or reloadTile to a worker. GeoJSONWorkerSource extends VectorTileWorkerSource but overrides loadVectorData, so it reads GeoJSON directly instead of fetching and parsing PBF vector tiles. geojson-vt converts the data to a tile-like representation, allowing the same getTile workflow to supply the main thread.
This design applies tile-based LOD to GeoJSON and lets the low-level renderer target one vector-tile-like representation instead of maintaining separate paths for vector tiles and GeoJSON.
ImageSource
Mapbox determines a sufficiently high-zoom tile whose bounds contain the ImageSource coordinates. loadTile() returns true only while the main thread requests that tile; the image itself was already requested when a layer using the source was added.
When vector or GeoJSON source data returns to the main thread, Tile#loadVectorData stores it in the tile’s buckets.
Preparing to Render
After the missing tile has loaded, control returns to SourceCache:
- SourceCache#_backfillDEM copies edge pixels from neighboring DEM tiles to prevent boundary artifacts.
- The source emits
data {dataType: 'source'}. The event bubbles throughSourceCache,Style, andMap, becomessourcedata, callsMap#_update(), schedulesMap#triggerRepaint(), and ultimately invokesMap#_render()for a new frame—the same rendering path triggered by a camera change.
Render Loop
When _sourcesDirty is false, Map#_render() draws the next frame directly on the main thread:
- Style#update() calls
recalculate()on every layer to update paint values for the current zoom and transition state. - SourceCache#update(transform) requests new tiles through the path described above.
- Painter#render(style) renders the current style:
- It calls
SourceCache#prepare(context)for every source. - For every tile in each source:
- Tile#upload(context) calls Bucket#upload(context) for each layer bucket, uploading vertex attributes required for drawing.
- Tile#prepare(imageManager) uploads image textures such as patterns and icons.
- Layers are drawn through four passes using
renderLayer()implementations under src/render/draw_*:- Offscreen: custom, hillshade, and heatmap layers precompute and cache data in offscreen framebuffers.
- Opaque: fill and background layers render opaquely from top to bottom.
- Translucent: other layer types render from bottom to top.
- Debug: collision boxes, tile boundaries, and other diagnostics render above the map.
- Each
renderLayer()iterates over visible tiles, binds textures, and uses shaders from src/shaders. Program#draw() configures GPU state and shader uniforms, then gl.drawElements() draws the tile for that layer.
- It calls
- If more rendering work remains, the repaint cycle continues. Otherwise, rendering completes and the map emits
idle.
Beyond Coordinates