Choosing Tile Extent and Buffer Values Jump to heading
Replace the two numbers everybody copies from an example with two numbers derived from your own style sheet and zoom range — and know what each one costs per tile.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Extent sets the resolution of the tile’s internal grid. A tile is conventionally rendered at 512 screen pixels; with an extent of 4096 that is eight grid units per pixel, which is comfortably finer than any display can show. Raising the extent adds precision nobody can see while making every coordinate delta larger and therefore more expensive to encode. Lowering it saves bytes at the cost of visible quantisation.
Buffer sets how far beyond the tile edge geometry is retained. Its purpose is entirely about rendering: a line styled eight pixels wide extends four pixels either side of its centreline, so if the centreline is clipped exactly at the tile edge, the outer four pixels of the neighbouring tile have nothing to draw. The result is a hairline seam that appears and disappears as you pan.
The conversion between the two units is the whole calculation. With an extent \(E\) and a tile rendered at \(P\) pixels, one screen pixel is \(E/P\) grid units. A style whose widest stroke is \(w\) pixels therefore needs at least
grid units of buffer. Labels need more, because a label anchored near the edge extends much further than a line: the half-width of the largest label box replaces \(w/2\).
Runnable solution Jump to heading
from __future__ import annotations
import logging
from dataclasses import dataclass
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.mvt.buffer")
TILE_PIXELS = 512 # the size one tile is rendered at
@dataclass(frozen=True)
class StyleFacts:
widest_line_px: float # the thickest stroke in the style
largest_icon_px: float # the biggest sprite, edge to edge
longest_label_px: float # the widest label box you expect to render
effect_margin_px: float = 4.0 # halos, blur, outline
def buffer_units(style: StyleFacts, extent: int = 4096,
tile_pixels: int = TILE_PIXELS) -> int:
"""Grid units of buffer needed so nothing is clipped mid-symbol."""
units_per_pixel = extent / tile_pixels
needed_px = max(
style.widest_line_px / 2.0,
style.largest_icon_px / 2.0,
style.longest_label_px / 2.0,
) + style.effect_margin_px
units = int(round(needed_px * units_per_pixel))
logger.info("%.1f px of overhang -> %d grid unit(s) at extent %d",
needed_px, units, extent)
return units
def extent_for_precision(min_zoom: int, max_zoom: int,
target_metres: float) -> int:
"""Smallest conventional extent giving <= target_metres per unit at max_zoom."""
circumference = 40_075_016.686
for extent in (256, 512, 1024, 2048, 4096, 8192):
unit_m = circumference / (2 ** max_zoom) / extent
if unit_m <= target_metres:
logger.info("extent %d gives %.2f m/unit at zoom %d (%.0f m at zoom %d)",
extent, unit_m, max_zoom,
circumference / (2 ** min_zoom) / extent, min_zoom)
return extent
logger.warning("no conventional extent reaches %.2f m at zoom %d",
target_metres, max_zoom)
return 8192
def estimated_overhead(buffer: int, extent: int) -> float:
"""Fraction of tile AREA that is buffer, as a first-order cost proxy."""
inner = extent
outer = extent + 2 * buffer
overhead = (outer ** 2 - inner ** 2) / inner ** 2
logger.info("buffer %d at extent %d covers %.1f%% extra area",
buffer, extent, overhead * 100)
return overhead
if __name__ == "__main__":
style = StyleFacts(widest_line_px=10, largest_icon_px=24,
longest_label_px=180)
extent = extent_for_precision(min_zoom=0, max_zoom=14, target_metres=1.0)
buf = buffer_units(style, extent=extent)
estimated_overhead(buf, extent)
Step-by-step walkthrough Jump to heading
- Take the maximum, not the sum. Only the largest symbol needs to fit; a tile does not need a buffer wide enough for a line and an icon and a label stacked together.
- Include labels in the maximum. A 180-pixel label box needs a 90-pixel overhang, which at extent 4096 and 512-pixel tiles is 720 grid units — many times what a line width alone would suggest.
- Add an effect margin. Halos, outlines and blur extend a symbol past its nominal box, and a few pixels of slack costs almost nothing.
- Derive the extent from precision, not from habit.
extent_for_precisionwalks conventional values and returns the smallest that meets the ground resolution you actually need at the maximum zoom. - Check the low-zoom end too. The same function reports metres per unit at the minimum zoom, which is the figure that explains blocky low-zoom coastlines to whoever asks about them.
- Measure the cost as area. The overhead calculation is a first-order proxy: a buffer of \(b\) around an extent \(E\) retains geometry over an area larger by \(((E+2b)^2 - E^2)/E^2\). At extent 4096 and buffer 64 that is about 6 percent; at buffer 720 it is over 90 percent.
- Reconcile the two numbers. A label-driven buffer this large is a signal to reconsider: most production pipelines put labels in a separate layer with a larger buffer, or accept that labels near edges are placed by the client from point anchors rather than from clipped geometry.
Verification Jump to heading
- Pan across a tile boundary at every zoom. Seams appear at specific zooms where a styled width crosses the buffer; testing one zoom proves nothing.
- Check the widest styled layer specifically. A buffer adequate for roads may be inadequate for a casing or an outline drawn wider still.
- Confirm labels near edges survive. Find a label anchored within a few pixels of a tile boundary and confirm it renders rather than flickering as you pan.
- Measure real tiles, not the formula. Generate a dense area at two buffer values and compare actual byte sizes; the area proxy overstates the cost where geometry is sparse near edges.
- Confirm extent does not exceed usefulness. At your maximum zoom, one grid unit should be comfortably finer than the finest detail in the source data, and no finer.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Hairline seams on thick lines | Buffer sized from a thinner layer | Size from the widest styled stroke in the whole style |
| Labels flicker near tile edges | Buffer sized from line widths only | Include the largest label box in the maximum |
| Tiles nearly double in size | Label-driven buffer applied to every layer | Give labels their own layer with its own buffer |
| Blocky geometry at high zoom | Extent too low for the maximum zoom | Raise the extent until one unit is finer than the data |
| Deltas unexpectedly large | Extent raised far beyond the visible need | Lower the extent; precision nobody sees costs bytes |
| Seams only at one zoom | Line width scales with zoom in the style | Compute the buffer at the zoom with the widest rendering |
| Buffer ignored by the client | Client clamps geometry to the tile | Check the renderer honours out-of-extent coordinates |
Specification reference Jump to heading
The
extentfield of a layer declares the width and height of the layer’s coordinate grid in tile-local units, and geometry coordinates are integers relative to the tile’s top-left origin. Coordinates outside the range 0 to extent are permitted and represent geometry retained beyond the tile boundary for rendering continuity. See the Mapbox Vector Tile specification for the extent field and the treatment of out-of-bounds geometry.
Frequently Asked Questions Jump to heading
Is 4096 always the right extent?
It is a sensible default and almost always adequate, because at the conventional 512-pixel tile rendering it gives eight grid units per pixel — finer than any display resolves. Raising it adds precision nobody sees while making deltas larger and tiles bigger. Lowering it is occasionally worth it for a deliberately coarse thematic layer, where quantisation is acceptable and every byte counts. Derive it from the ground resolution you need at your maximum zoom rather than adopting it reflexively.
How do I choose the buffer without knowing the final style?
Pick a defensible upper bound and record the assumption. A buffer of 64 grid units covers a stroke up to about sixteen screen pixels at the conventional extent, which is wider than most base-map lines. Write down what that buffer assumes, so when a designer later specifies a thirty-pixel casing, somebody can connect the resulting seams to the assumption rather than treating them as a mysterious rendering bug.
Why do labels need so much more buffer than lines?
Because a label extends outward from its anchor by half its rendered width, and a long place name is easily two hundred pixels wide where a line is ten. Sizing the buffer to accommodate labels can nearly double the geometry retained in every tile. The usual resolution is to separate concerns: keep label anchors as points in their own small layer with a generous buffer, and leave the geometry layers with a buffer sized for strokes.
Does a larger buffer always mean larger tiles?
In proportion to how much geometry actually sits near the edges, which varies enormously. A tile covering open countryside gains almost nothing from a wider buffer; a tile covering a dense city centre gains a lot, because every street near the boundary is retained further. That is why the area-based estimate is only a first-order proxy and why the decision should be confirmed by generating a dense sample at both values and comparing real bytes.
Related Jump to heading
- The Mapbox Vector Tile Spec & Tile Geometry — the parent topic and the grid these constants configure.
- Debugging Features Clipped at Tile Edges — what to do when the buffer turns out to be wrong.
- Encoding OSM Geometry into MVT with Python — where these constants are applied.
- Simplifying OSM Geometry per Zoom Level — choosing a tolerance in the same grid units.
- OSM Vector Tiles & Rendering Pipelines — the size budget these choices spend.
Up one level: The Mapbox Vector Tile Spec & Tile Geometry.