A map worth having
September 4, 2026 · View on GitHub
The design record behind <Map>: what the formats
actually are, who serves them, what the "extra layers" everyone asks for
turn out to be, and the measurements that produced the rendering
architecture. The reference page is the API; this is why it has that shape.
Status: implemented, for 2D, north-up, vector and raster tiles.
The survey
Tile formats
Two formats matter, and one of them is the whole open half of the industry.
Mapbox Vector Tile (MVT), currently at spec 2.1, is a Protocol Buffers
message: a tile is layers, a layer is features plus a deduplicated table of
keys and values, and a feature's geometry is a stream of packed uint32
commands — MoveTo, LineTo, ClosePath — with zigzag-encoded deltas from
a cursor. Coordinates are integers in a per-layer extent (4096 by
convention) that divides the tile's own square, and geometry is allowed to
run past that square, which is the buffer that makes a road's join right
where two tiles meet. Mapbox, MapTiler, Protomaps, Esri, TomTom, Azure Maps
and OpenStreetMap's own tile server all serve it. The specification is
short, and src/maps/mvt.ts implements it whole.
Three details in it are where a decoder goes quietly wrong, so they are
called out in the code and pinned by tests: the geometry cursor persists
across parts, so a multi-part feature whose second MoveTo resets it
draws in the wrong place; a ring's last point is not repeated before
ClosePath, so the closing edge has to be supplied; and an exterior ring
is defined as one with positive area by the surveyor's formula in tile
coordinates, where y increases downward — which is what lets a whole
multipolygon be filled with the non-zero rule and get its holes for free.
Raster tiles are PNG or JPEG or WebP over the same {z}/{x}/{y} slippy
scheme (or WMTS, or the older TMS with its flipped y). There is nothing to
decode but the image, which is why <Map> takes raster tiles as pixels and
leaves the codec to the application.
Two container formats are worth knowing about and are not implemented:
PMTiles (a single file with a 127-byte header, addressed by HTTP range
requests, which is how Protomaps ships a whole-planet basemap with no
server) and MBTiles (the same tiles in SQLite). Both are ways of
getting tiles, so both are a MapSource an application writes; neither
needs anything from this component.
Schemas
The format says how bytes are laid out; a schema says which layers exist and what their fields mean, and they are not interchangeable.
- Shortbread — OpenStreetMap's own, at version 1.1, served from
vector.openstreetmap.orgsince 2025. Deliberately lean and general-purpose. This is whatshortbreadStyle()is written against, because it is the one open schema with an open, keyless, canonical server behind it. - OpenMapTiles — the older open schema, what MapTiler serves and what most self-hosted planet extracts use.
- Mapbox Streets v8, Protomaps basemap, Azure Maps, TomTom Orbis — each a vendor's own, each documented, each different.
Nothing about the component is tied to a schema: a style names source
layers and fields, so a second schema is a second style — and both of the
open ones ship. shortbreadStyle() reads OpenStreetMap's own server and
VersaTiles; openMapTilesStyle() reads MapTiler, Stadia, Geoapify,
OpenFreeMap and most self-hosted planets. They are deliberately the same
palette and the same layer ids where those mean the same thing, so moving a
source between schemas changes which style is passed and nothing about how
the map looks. Passing the wrong one matches nothing and draws an empty map,
which is the one failure to expect and is not an error: a style naming a
layer a tile does not have is ordinary.
Providers
| provider | 2D tiles | usable here |
|---|---|---|
| OpenStreetMap | Shortbread vector, and the classic raster style | Yes, keyless, open licence. Both adapters ship. |
| MapTiler, Stadia, Protomaps, Thunderforest | OpenMapTiles or their own MVT | Yes — a URL template and a key, which is a MapSource of five lines. |
| Microsoft Azure Maps | MVT (microsoft.base, .labels, .hybrid) plus raster | Yes, with a subscription key. Documents its schema. |
| Google Maps | Map Tiles API: 2D raster tiles — roadmap, satellite, terrain | Yes, as raster, and googleTileSource() ships. A session token carries the display options, so mapType and language replace a style. No vector endpoint and no published schema, so no style could be written; their attribution and caching terms are the application's. |
| Apple | MapKit / MapKit JS only | No. There is no documented tile endpoint, and their terms forbid scraping one. |
The two closed ones are recorded here so the question is not reopened. Google's tiles are reachable and now have an adapter, but only as finished images: what their docs call vector "roadmap" tiles are rasterized server-side and arrive as PNG, so the schema question is moot rather than merely unanswered — there is no vector wire format to have a schema for. Apple's are not reachable at all: MapKit JS is a rendered map, not a tile service.
The layers everyone asks for
The useful finding is that "traffic", "routes" and "transit" are not three features. They are the same two or three geometric shapes arriving in different envelopes.
- Traffic — TomTom serves flow and incidents as MVT (layers
Traffic incidents flowandTraffic incidents POI) and as raster; Azure Maps serves bothpngandpbf; HERE and Google serve a raster overlay. So: either another tile source over the basemap, or a set of coloured line overlays. Both work today, and the standards behind the data (TPEG, DATEX II, TMC location codes) never reach the renderer. - Routes — every routing engine on the open web (Google Directions,
OSRM, Valhalla, GraphHopper, Mapbox Directions) answers with an
encoded polyline, which is why
decodePolylineis in the package rather than in the example. A route is then onelineoverlay, and thecasingfield is what makes it readable over a road of the same colour. - Transit — GTFS static gives shapes as encoded polylines in
shapes.txtand stops as points; GTFS-Realtime is a protobuf feed of vehicle positions, each a latitude and longitude with an optional bearing. So: a line overlay per shape, and a marker per vehicle, updated as the feed arrives. That last case is whymarkersdiffs into marker-sized damage rather than repainting the pane. - Terrain — Terrarium and Mapbox Terrain-RGB encode elevation in a PNG's channels. Reachable as a raster source, but nothing here interprets it; hillshading is a renderer feature, not a format one, and is not implemented.
- Everything else an application has — GeoJSON, and
geoJsonOverlaysturns it into markers and overlays. TopoJSON, WKT, Shapefile, FlatGeobuf and GeoPackage are all conversions an application does before it gets here.
The shape of the component
It draws; it does not compose
AGENTS.md states the rule: ask whether the feature's viewport is a
transform. A map's is. This renderer's style vocabulary has no transform,
so a composed map would have to re-render every road through React and
re-lay-out every road through yoga on every pointer step of a pan. So
<mapview> is one registered element that draws the whole map, which is
<Flow>'s shape.
What a map adds to <Flow>'s case is that the scene arrives a tile at a
time and costs tens of milliseconds a tile to draw. That is what the rest
of this document is about.
Nothing fetches
<Html onResource>'s rule, for the same reason: a component whose default
made requests would decide, on the application's behalf, whose servers it
talks to, what its user agent says, and whose usage policy it is now bound
by. So a MapSource is a load function the application supplies, and the
OSM adapters supply the URL, the schema and the attribution around it.
The attribution is the part that is not merely tidy. For OpenStreetMap-derived
tiles it is a licence condition, so a source carries it, the map draws it,
and attribution="" is how an application says it has put it elsewhere.
Two things the first cut of this seam got wrong, both found by running the
example against the real network rather than against the test corpus, and
both now pinned by tests. The signal handed to load was a plain object
with an aborted getter; fetch checks instanceof AbortSignal and throws
TypeError on anything else, so every load failed in exactly the way the
documentation told people to write. And there was no way to see that: a
failed tile draws nothing, so a map whose every tile fails is pixel-identical
to one that is still loading. onTileError and MapFrameStats.errors exist
because of that, and the retry is on a backoff (0.5 s doubling to 30 s)
because the same bug had every visible tile re-asked once a frame.
The style is a subset of the GL style spec
Shaped after Mapbox/MapLibre's specification because every provider
documents their schema in its terms, so a fragment of Shortbread's or Azure
Maps' documentation reads the same way here. Four layer types, the spec's
legacy filter syntax, and { stops } for zoom ramps.
The expression language is deliberately absent. It is a small typed interpreter with a cost per feature, and a dense tile has twelve thousand features and eighteen layers — so anything computed per layer is free and anything computed per feature is the frame. Filters compile to closures once per style; paint values resolve once per layer per frame.
The one place this bites is symbol ranking. A schema's own importance field
looks like the obvious input for "which label wins" and is not usable as
one: Shortbread carries population, which is 8,000,000 for London and 500
for Soho, and normalizing that against a rank that also has to order
against street names is a guess. Splitting by filter — a layer for
cities, a layer for suburbs — is exact, and is what the ordering wanted
anyway.
The rendering architecture
Three caches, layered, and the whole performance argument is the layering.
- Tile data, keyed on
source/z/x/y, valid forever: a tile's contents do not depend on where the camera is. - Up to two rendered
Surfaces per tile — the one on screen and the one being drawn — each valid for a zoom level and a style but not for a camera position. Past the source's own depth the cover synthesizes tiles, so these are per synthesized tile — 4,096 of them share one zoom-14 fetch at zoom 20, and only the handful on screen are built. So a pan composites the same surfaces at new offsets, and a fractional zoom composites them scaled. Neither rasterizes anything. Only crossing an integer zoom does, and while it redraws, the previous picture stays up. - A label placement in world pixels, valid for a zoom and a set of loaded tiles — so a pan translates it rather than recomputing it.
Around them:
- A pan is a blit.
scrollContents(react-x11#303) claims the pane, arms the frame to shift the band that survives inside the backing store, and narrows the claim to the strip that was exposed — whichpaintDamage()then hands to the paint. The attribution strip is carved out of the shifted region and claimed the ordinary way, because it is pinned to the pane and must not ride the blit. - The uncontrolled camera lives on the element, not in a
useStateabove it. This is the difference between a drag step costing two numbers and a strip, and costing a render, a commit and a full-pane damage claim. A controlled camera puts the application back in the loop, which is its right, andselfDamagedPropskeeps the commit from claiming the pane. - Rasterization is budgeted and resumable, by style layer. A frame
spends at most
rasterBudgetMs(8 by default) on it and remembers where it stopped — but always draws at least one tile, because a budget smaller than one unit of work is not "do less" but "do nothing", and a frame that finished nothing asks for another one, forever. The budget is measured onperformance.now():Date.now()counts whole milliseconds, which is 12% of error on an 8 ms budget and rounds anything under 1 ms to a deadline that has already passed. - A tile is composited when it is finished, not while it is being drawn.
Composited as soon as its surface exists, a dense tile arrives as water,
then landuse, then casings, then roads, across a dozen frames — honest
about the renderer and unlike any other map.
progressiveopts back in. What waiting costs is a transition that invalidates every surface at once (a style change,refresh(), a scale change): no finished tile and no finished ancestor, so the map shows its background until the new tiles land. Holding the old picture through that needs a second surface per tile — draw into a draft, swap on completion — which doubles the surface memory of every tile being redrawn and is deliberately not done here. - A gesture rasterizes nothing. Any camera move sets the budget to zero for 140 ms, so a drag or a wheel is composites only and the map sharpens when it stops.
- A frame that only continues a rasterization claims one pixel, not the pane. Damage is the only "call me next frame" an element has, and nothing on screen changes while a tile is being drawn into its second surface — the frame where it lands claims that tile's box instead. Measured over a wheel-shaped zoom on the Cocoa backend: ten post-gesture frames, of which one repaints anything, where every one of them used to.
- A hole is covered from whichever side has pixels. A tile with no rendering yet borrows the nearest coarser ancestor, scaled up — which is why zooming in sharpens rather than flashing empty — or, when there is none, its finer descendants, scaled down, which is the zoom-out case and the one the first cut missed entirely: walking up the pyramid finds nothing when the tiles in hand are below you, so a zoom out showed the background, labels and markers still drawn over it, until the coarser tile had been fetched, rasterized and composited. Descendants win when they cover the whole square (sharper, and the level being left); the ancestor wins when they do not (complete beats sharp-with-holes).
Labels
Collected from the symbol layers, placed greedily by rank, and drawn into the frame rather than into the tiles. Three reasons, each a visible bug if it is done the other way: collision is global, so per-tile placement is clutter at every seam; a tile surface is composited at up to 2× during a fractional zoom, and text is the one thing nobody accepts blurred; and a tile's labels would be clipped at its edge, which is where half of them sit.
Placement is computed in world pixels rather than screen pixels. That is what makes it survive a pan — whether a label wins depends only on the labels near it and the zoom — and therefore what keeps the pan a blit.
The profile
Everything below is measured on the corpus scripts/bench/tiles.ts
fetches: real OpenStreetMap Shortbread tiles for Manhattan, central London,
Tokyo and a mid-Pacific control, 103 tiles over zooms 0–14. Reproduce with
npx tsx scripts/bench/maps.ts.
The corpus is worth stating, because the numbers only mean anything against it:
z tiles features/tile vertices/tile KB/tile layers
0 1 1 22231 51 1
6 4 9474 55376 324 7
8 4 13049 154091 603 8
10 16 6534 95456 344 9
12 35 4258 31077 162 18
14 36 5474 43840 233 23
The decoder runs the whole of it — 541,051 features, 5,185,205 vertices —
with zero anomalies: no polygon without an exterior ring, no empty
geometry, and extent values of 2048 and 4096 in the same tile, which is
what taught the renderer to read extent per layer rather than per tile.
Decoding
0.7–11 ms per tile, protobuf plus geometry, identical on both backends. Not
the bottleneck, and the reason the decoder reads into caller-owned typed
arrays rather than returning arrays of {x, y} objects: the convenient
shape would allocate about 100,000 objects per dense tile, for one
rasterization.
Rasterizing one tile
Median of 7, a 512-logical-pixel tile at display scale 2 (a 1024-pixel surface). "First draft" is the straightforward implementation; "tuned" is what shipped.
| zoom | tile | x11 first | x11 tuned | cocoa first | cocoa tuned |
|---|---|---|---|---|---|
| 0 | 0/0/0 | 7.4 | 7.4 | 4.8 | 2.8 |
| 6 | 6/18/24 | 88.4 | 62 | 210.7 | 54 |
| 8 | 8/227/100 | 161.9 | 121 | 475.7 | 134 |
| 10 | 10/511/340 | 127.4 | 115 | 309.5 | 140 |
| 12 | 12/3638/1612 | 137.7 | 122 | 263.9 | 101 |
| 14 | 14/8185/5447 | 58.9 | 49 | 426.9 | 77 |
What produced the difference, in the order the profiler found it:
- A CPU profile said 42% of active time was in ntk's software
rasterizer (
toAlphaandedgeinrasterize.js), with another 60% of wall-clock idle uploading megabyte coverage masks. Forcing ntk's server-side trapezoid path instead — the obvious next thought — is catastrophically slower: a single zoom-12 tile did not finish in 280 seconds, on XQuartz and on X.Org's Xvfb alike. ntk's heuristic is right, and there is no escape hatch here to reach for. - Per-part culling, not per-feature. OSM's
landlayer at zoom 8 is a single feature whose geometry is thousands of separate rings, most of them a fraction of a pixel across. Culled as one feature it is never culled. - Simplification against the target grid — a radial test and then a perpendicular one, both in the same vertex loop. The perpendicular test is the one that matters on real data: a generalized coastline's vertices are about a pixel apart, which the radial test keeps, and nearly collinear, which this drops.
- One seek pass per run of style layers over one source layer. A road
network is fourteen style layers over
streets(seven classes, casings and fills), so a layer-at-a-time walk parses each of 8,488 features' headers and tags fourteen times over. - A tag cache on the feature cursor, so those fourteen filters read decoded integers rather than re-decoding varints.
- The path flush size was per backend, until core fixed the reason.
On X11 a fill becomes an a8 coverage mask uploaded with one
PutImage, so a bigger path is fewer uploads over the same pixels: 12,000 vertices measured best, 500 measured 25% worse. On the Cocoa backend the path went toCGContextStrokePath, whose cost was quadratic in the number of subpaths: 512 measured best and 12,000 measured three times worse, so this element probed the backend and picked. react-x11 2.6.1 chunks that stroke inside the backend, which is where the knowledge belongs, and one constant now serves both — see "The gap, upstream" below. - The flush has to be able to interrupt a single feature. The finding
that fixed Cocoa's zoom 14:
buildingsin a central London tile is one feature with 26,314 vertices, and flushing only between features left it as one 347 msCGContextStrokePath. Flushing inside the part loop brought that layer to a fraction of it — but only at a polygon boundary, which is whatareasis for: flushing between an exterior ring and its interior ones fills the hole in.
A whole frame
1200×800 at scale 2, on the densest zoom-12 city block in the corpus.
| backend | phase | frames | draw median / p95 | raster median / max |
|---|---|---|---|---|
| x11 | settle | 28 (1877 ms) | 3.5 / 5.0 ms | 13 / 55 ms |
| x11 | pan | 16 | 6.0 / 11.0 ms | 0 / 0 ms |
| x11 | zoom | 4 | 1.5 / 5.0 ms | 0 / 0 ms |
| cocoa | settle | 64 (1302 ms) | 8.0 / 9.0 ms | 8 / 44 ms |
| cocoa | pan | 119 | 0.0 / 1.0 ms | 0 / 0 ms |
| cocoa | zoom | 9 | 6.0 / 22.0 ms | 0 / 0 ms |
The zeroes are the point. A pan and a fractional zoom rasterize nothing; they composite surfaces that already exist. A cold dense city view fills in over one to two seconds, in frames that are individually cheap.
Before the budget could interrupt a run, the same settle measured a median of 47 ms and a maximum of 192 ms per frame on x11 — one style layer, uninterruptible. The remaining 44–55 ms maximum is the same shape one level down: a single layer that is one enormous multipolygon. Splitting inside a layer is the next thing to do if it matters.
The gap, upstream, and how it closed
CGContextStrokePath was superlinear in the number of subpaths
(react-x11#456). One
path holding a zoom-14 tile's buildings layer — a single MVT feature
with 26,314 vertices in about two thousand rings — measured 347 ms; the
same rings flushed in batches of 512 vertices measured a fifth of that. A
CPU profile of the Cocoa raster stage put 76.2% of all samples inside
that one native call.
Core fixed it in 2.6.1 (react-x11#457): the Cocoa context splits a stroke at subpath boundaries, at 512 points or 128 subpaths, with the hairline and compositing cases left whole because splitting those is a loss. Core measured it quadratic — 500 rings 20 ms, 4,000 rings 986 ms, against 14 ms and 113 ms chunked.
And the fix is why this package no longer batches per backend. The right chunk is a fact about CoreGraphics that no caller can know, and the two backends wanted opposite values, so a caller that batched for one pessimized the other. With the chunking in the backend, the X11-shaped number is the only one needed — re-measured on this corpus against 2.6.1:
| tile (cocoa) | caller batch 12,000 | caller batch 512 |
|---|---|---|
8/227/100 | 114 ms | 142 ms |
10/511/340 | 114 ms | 145 ms |
12/3638/1612 | 96 ms | 101 ms |
14/8185/5447 | 87 ms | 82 ms |
So batching small on Cocoa now costs 20-25% at the zooms that hurt, by
cutting the path before core can chunk it well. batchVertices stays as a
prop because the number is still a real X11 trade; its default is one
constant.
And a thing that looked like a second gap and was not, recorded because
it was the first hypothesis and the measurement refuted it. The Cocoa 2d
context makes one napi call per path operation — moveTo, lineTo and
closePath each cross the boundary — so a 45,000-vertex path is 45,000
crossings, and batching them looked like the obvious fix. It is not where
the time goes: in the same profile lineTo is 18 ms of 3,143, 0.6%, and
flushing the path in batches — which does not reduce the number of lineTo
calls at all — still gave a five-fold improvement. The crossings are cheap;
the stroke was not.
Three bugs the test corpus could not have found
Recorded because each was invisible to a suite that passed, and each was found by running the thing.
-
The
signalhanded to a source was not anAbortSignal. It was a plain object with anabortedgetter, andfetchchecksinstanceof AbortSignaland throwsTypeErroron anything else — so every load failed in exactly the way the documentation prescribed. The headless tests never saw it because their sources answer synchronously and never callfetch. -
Nothing reported that. A failed tile draws nothing, so a map whose every tile fails is pixel-identical to one that is still loading. There was no signal at any layer — no callback, no counter, no log — and the first report was a screenshot of an empty map.
onTileErrorandMapFrameStats.errorsexist because of it, and the retry moved to a backoff because the same bug had every visible tile re-asked once a frame. -
Nothing was clipped to the viewport, and it turned out to be two bugs with two different limits, both thrown from inside
paintwhere no application can catch them.Three or four zoom steps in, an overlay overflowed: an overlay is geography, a world is
512 · 2^zoompixels (134 million at zoom 20), and ntk hands a stroke to XRender in 16.16 fixed point, which overflows a signed 32-bit word at 32,768. Lines are now cut segment by segment, rings clipped as rings, and an over-large circle drawn as a clipped ring.Past zoom 20 the tile composite overflowed, on a different limit: XRender takes composite coordinates as int16, and an overzoomed tile dwarfs the pane — at zoom 22 against a pyramid that stops at 14 a tile is 131,072 logical pixels across, so one that overlaps the pane starts 73,000 pixels outside it. The destination is clipped and the source rectangle moved to match, which leaves
sw/dwexactly what it was.Every headless test framed its content, so none of them had anything far enough out — which is the lesson rather than the fix. A test that only ever looks at what it is drawing cannot find a coordinate-range bug, and both regressions now zoom until they would have thrown.
Overzoom is sub-tiling, not stretching
The first cut clamped the cover to the source's depth and let the composite stretch what it found. That is what every simple client does and it is visibly wrong past a couple of levels: OSM cuts Shortbread to zoom 14, so a zoom-21 view drew one tile rasterized at 2,048 pixels and stretched to 131,072 — a 64× bitmap upscale of data that had far more left in it.
| zoom | before: raster → on screen | after |
|---|---|---|
| 15 | 2048 → 2048 (1.0×) | 1.0× |
| 17 | 2048 → 8192 (4.0×) | 1.0× |
| 20 | 2048 → 65536 (32×) | 1.0× |
| 21 | 2048 → 131072 (64×) | 1.0× |
| 22 | 2048 → 262144 (128×) | 2.0× |
Instead the cover goes up to six levels deeper than the source cuts, and
the data for those tiles comes from their ancestor at the cut level. A
zoom-20 tile is one cell of a 64×64 grid over one zoom-14 tile: one fetch,
one parse, 4,096 possible renderings, each rasterized at its own natural
size with the parent's geometry positioned by a negative origin and a span
64 × the surface.
Two things make that affordable rather than 4,096 times the work:
- A feature whose box misses the cell is skipped before it becomes a path. The box came free from the geometry read. Measured on a dense central-London tile: the whole z14 tile is 56 ms, one of its four z15 cells 37 ms, one of 256 z18 cells 7.7 ms, one of 4,096 z20 cells 6 ms. A deep cell is cheaper than a whole tile, and only the handful on screen are ever built.
- The geometry is clipped, not just culled. A feature that spans the
cell has vertices far outside it — a tile-wide polygon is 64 tiles wide in
the cell's pixels — and those coordinates overflow XRender's 16.16 fixed
point at 32,768 exactly as an unclipped overlay did.
src/maps/clip.tsis the two algorithms both paths now share.
Six levels is the cap because the data runs out there: at zoom 20 one unit of a zoom-14 tile's 4,096-unit grid is 16 device pixels across, so there is no more shape in the tile to draw.
What is not here
Recorded so the questions are not reopened as bugs.
- Rotation and pitch. A north-up 2D map. Both are real features and neither is small: a rotated viewport changes the tile cover (an axis-aligned rectangle in screen space is a rotated one in tile space), the label placement, and every hit test. Pitch additionally needs per-vertex perspective, which is a different rasterizer.
- Line and area label placement. Labels are placed at a point; a street name should follow its street and a country name should sit at its polygon's pole of inaccessibility. The anchor is implemented and the curve is not.
- Icons in symbol layers. Text only. Sprites are a second asset pipeline (a sheet, an index, a scale factor), and the same thing is reachable today by putting a marker where the label would be.
- Clustering. A
markersarray of ten thousand points is ten thousand markers. Supercluster-shaped grouping is a pure function over positions and a zoom, which makes it an application's to run and amarkersarray to pass in — and it is a candidate forsrc/maps/if two applications write it. - Terrain, hillshading and 3D buildings. Out of scope for "2D only".
- A style-spec parser.
mapStyleis a typed object. Reading a realstyle.jsonfrom a provider means the expression language, sprites, glyphs (PBF SDF fonts) and the rest of the spec; it is a plausible separate module and not this one.