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.org since 2025. Deliberately lean and general-purpose. This is what shortbreadStyle() 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

provider2D tilesusable here
OpenStreetMapShortbread vector, and the classic raster styleYes, keyless, open licence. Both adapters ship.
MapTiler, Stadia, Protomaps, ThunderforestOpenMapTiles or their own MVTYes — a URL template and a key, which is a MapSource of five lines.
Microsoft Azure MapsMVT (microsoft.base, .labels, .hybrid) plus rasterYes, with a subscription key. Documents its schema.
Google MapsMap Tiles API: 2D raster tiles — roadmap, satellite, terrainYes, 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.
AppleMapKit / MapKit JS onlyNo. 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 flow and Traffic incidents POI) and as raster; Azure Maps serves both png and pbf; 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 decodePolyline is in the package rather than in the example. A route is then one line overlay, and the casing field is what makes it readable over a road of the same colour.
  • TransitGTFS static gives shapes as encoded polylines in shapes.txt and 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 why markers diffs 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 geoJsonOverlays turns 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.

  1. Tile data, keyed on source/z/x/y, valid forever: a tile's contents do not depend on where the camera is.
  2. 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.
  3. 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 — which paintDamage() 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 useState above 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, and selfDamagedProps keeps 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 on performance.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. progressive opts 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.

zoomtilex11 firstx11 tunedcocoa firstcocoa tuned
00/0/07.47.44.82.8
66/18/2488.462210.754
88/227/100161.9121475.7134
1010/511/340127.4115309.5140
1212/3638/1612137.7122263.9101
1414/8185/544758.949426.977

What produced the difference, in the order the profiler found it:

  1. A CPU profile said 42% of active time was in ntk's software rasterizer (toAlpha and edge in rasterize.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.
  2. Per-part culling, not per-feature. OSM's land layer 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.
  3. 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.
  4. 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.
  5. A tag cache on the feature cursor, so those fourteen filters read decoded integers rather than re-decoding varints.
  6. 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 to CGContextStrokePath, 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.
  7. The flush has to be able to interrupt a single feature. The finding that fixed Cocoa's zoom 14: buildings in a central London tile is one feature with 26,314 vertices, and flushing only between features left it as one 347 ms CGContextStrokePath. Flushing inside the part loop brought that layer to a fraction of it — but only at a polygon boundary, which is what areas is 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.

backendphaseframesdraw median / p95raster median / max
x11settle28 (1877 ms)3.5 / 5.0 ms13 / 55 ms
x11pan166.0 / 11.0 ms0 / 0 ms
x11zoom41.5 / 5.0 ms0 / 0 ms
cocoasettle64 (1302 ms)8.0 / 9.0 ms8 / 44 ms
cocoapan1190.0 / 1.0 ms0 / 0 ms
cocoazoom96.0 / 22.0 ms0 / 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,000caller batch 512
8/227/100114 ms142 ms
10/511/340114 ms145 ms
12/3638/161296 ms101 ms
14/8185/544787 ms82 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 signal handed to a source was not an AbortSignal. It was a plain object with an aborted getter, and fetch checks instanceof AbortSignal and throws TypeError on 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 call fetch.

  • 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. onTileError and MapFrameStats.errors exist 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 paint where no application can catch them.

    Three or four zoom steps in, an overlay overflowed: an overlay is geography, a world is 512 · 2^zoom pixels (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/dw exactly 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.

zoombefore: raster → on screenafter
152048 → 2048 (1.0×)1.0×
172048 → 8192 (4.0×)1.0×
202048 → 65536 (32×)1.0×
212048 → 131072 (64×)1.0×
222048 → 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.ts is 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 markers array 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 a markers array to pass in — and it is a candidate for src/maps/ if two applications write it.
  • Terrain, hillshading and 3D buildings. Out of scope for "2D only".
  • A style-spec parser. mapStyle is a typed object. Reading a real style.json from 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.