The web (wasm) build
The same all/native core, compiled by emscripten and rendering into a <canvas> through WebGL 2.
It exists for the style preview on the website — and as a development loop that does not need an
Android emulator.
source ~/emsdk/emsdk_env.sh
python3 scripts/build-web.py --profile lite --configuration RelWithDebInfo --build-demo
python3 web/demo/serve.py # http://localhost:8088
Add --website to put the same module under website/static/preview, which is what the
documentation site's style preview page runs.
Size, measured with emscripten 6.0.9 on the lite profile (the Release .wasm rows re-measured
2026-09-30, after the allocator and the -O2 hot libraries):
| Artefact | Release | RelWithDebInfo |
|---|---|---|
.wasm | 5.78 MB | 354 MB |
.wasm gzipped | 2.19 MB | — |
.wasm brotli | 1.61 MB | — |
.mjs loader | 265 KB | 429 KB |
DWARF is the whole difference: RelWithDebInfo is 78x the size and is a development artefact
only - never quote it, and never deploy it. Over the wire the number that matters is the compressed
one, and GitHub Pages serves brotli, so the module costs about 1.6 MB.
A preloaded font adds its own .data file on top (3.0 MB for the bench's fonts and styles; Roboto
alone is 306 KB).
What has been tried on size, and what it cost
Release compiles with -Oz, except the libraries a tile decode and a frame run in (vt,
mapnikvt, cartocss, freetype, tess2, zlib, brotli, zstd), which are -O2 as on Android
and iOS - measured in the allocator. The table below predates that. Two things
that look like free wins are not, both measured on the same tree:
.wasm | gzipped | brotli | |
|---|---|---|---|
-Oz, as shipped | 4 687 926 | 1 823 110 | 1 367 486 |
+ -flto=thin | 4 800 168 | 1 845 541 | 1 385 803 |
+ -flto (full) | 4 978 620 | 1 877 009 | 1 408 265 |
LTO makes it bigger - 2.4% thin, 6.2% full, and still bigger after compression. Cross-module
inlining wins back less than it spends, which is the usual way -Oz and LTO disagree: -Oz is
already refusing the inlines LTO then goes and takes. The Android build uses -flto=thin and is a
different trade; do not copy it here without measuring.
Passing -Oz at LINK as well changed the output by zero bytes - emcc already runs wasm-opt
from the compile flags recorded in the objects - so it is not set either.
What is left is emscripten's closure pass on the loader (265 KB, so worth little) and dropping pthreads, which is not available: the tile pools need them.
Use --configuration RelWithDebInfo while developing, but do not quote its size: it carries DWARF
and the .wasm comes out around 340 MB.
The bench
web/demo is the browser's answer to the Android demo's intent extras: the map is configured from
the query string, so a camera and a style are a link.
| Parameter | Meaning |
|---|---|
lon, lat, zoom | The camera. Defaults to Paris at z12 |
source | Tile URL template. A .png/.jpg/.jpeg/.webp in it means raster, anything else vector |
css | URL-encoded CartoCSS, used when the source is vector |
project | A CartoCSS project directory under web/demo/styles, preloaded like the fonts |
style | Which entry file of that project to use, without its extension. Default project |
terrain | A DEM tile URL or .pmtiles archive; turns on 3D terrain |
terrainMaxZoom | Deepest level of that DEM. Default 12, which is Mapterhorn's |
project is how a converted MapBox style is previewed:
curl "https://api.mapbox.com/styles/v1/mapbox/standard?access_token=$TOKEN" -o standard.json
node tools/style-cli/dist/cli.js mapbox2css standard.json web/demo/styles/mapbox-standard \
--source-schema mapbox
Pass --sprite-key or there are no POI icons at all: Mapbox serves the sprite sheet from the
style's own URL, which needs the token, and without it the converter says
Sprite not loaded ... icons will be dropped and carries on.
Mapbox Standard's 150 layers convert and render. CompiledStyleSet wants the entry file at the
root of its package, so the package is the project directory and the style name is the file
without its extension - project, or night for one of the themes the converter also writes.
Its labels name fonts nobody has (DIN Pro Medium), and MBVectorTileDecoder asks for a font by
name STRICTLY so that a font list can fall through to its next entry. The bench therefore registers
every TTF in /fonts with addFallbackFont, which is what makes the labels appear at all.
?zoom=14&source=<url-encoded MVT template>&css=<url-encoded CartoCSS>
The style is passed in rather than fetched because main() runs on the browser's main thread,
where a synchronous fetch is illegal. An app does not use the bench: it loads the SDK module
(below).
Copy web/demo/config.example.json to web/demo/config.json to set what /demo/ shows with no
query string at all. It is gitignored because it holds a tile-provider token; the query string still
wins over it. The buttons in the corner fly the camera through the facade, which is the shortest
example of driving the map from JavaScript.
Gestures
WebMapView handles the mouse itself and leaves touch to the SDK's TouchHandler, which already
knows pinch and two-finger rotate. The speeds are ported from maplibre-gl-js
(src/ui/handler/mouse.ts, scroll_zoom.ts) so the map feels like any other web map:
| Input | What it does | Constant |
|---|---|---|
| Drag | Pan | the SDK's own |
| Wheel | Zoom toward the pointer | 1/450, sigmoid 2/(1+exp(-abs(Δ·rate))) |
| Trackpad pinch (wheel + ctrl) | Zoom toward the pointer | 1/100 |
| Right-drag, or ctrl-drag | Rotate and tilt; about the ground under the pointer with Options.OrbitAroundPivot | 0.8 and 0.5 degrees per CSS pixel |
| Double-click | Zoom in 1, shift for out, toward the pointer | 0.3 s |
Two sign traps, both found by feel and then explained:
- Both rotate and pitch are negated against maplibre's constants. maplibre turns the camera's
bearing where
rotate()turns the map under it, and this SDK's tilt is 90 looking straight down where mapbox's pitch is 0 there. Taken as written the map moved the wrong way on both axes. - The speeds are per CSS pixel, but pointer coordinates are scaled to device pixels for
onInputEvent, so the delta has to be divided back out or a drag turns the map twice as far on a 2x display.
A mouse also needs a much smaller click tolerance than a finger: ClickHandlerWorker would not
start panning until the pointer had travelled 0.2 inch, which is about 32 CSS pixels and made every
drag feel stuck. Options.clickMovingTolerance names that threshold in dp - still 32 by default, so
Android and iOS are unchanged - and the web host sets 3, which is maplibre's clickTolerance.
Zoom: Options.ZoomOffset, and the three places a convention hides
The SDK calibrates zoom the way a 256-pixel slippy map does; maplibre and mapbox-gl calibrate on a
512-pixel tile, so the same NUMBER is one level closer there. Measured at zoom 13 in Paris on a 2x
display, before: massif 12.5729 m per CSS pixel against maplibre's 6.2864 - a ratio of exactly
2.0000. The web host now sets Options.ZoomOffset = 1 and the same measurement gives 6.2864,
ratio 1.00000. Every other platform keeps the SDK's own convention; the option defaults to 0.
It is a renumbering and nothing else. Offset 1 at zoom 13 is offset 0 at zoom 14 - the same camera, the same tiles, the same picture, a different number. That was checked by screenshotting both and comparing: identical down to the street labels, with only the readout differing.
Getting there needed three things to move together, and each of the first two was tried alone and was wrong in a way that looks like the answer:
| The camera | ViewState::calculateZoom0Distance. Alone, it renumbers the camera and leaves the tiles behind, so the same tile is drawn twice as large - every label and line with it. |
| The tile level | TileLayer's targetTileZoom cap. It is this cap, not the screen-area rule, that ties a tile level to the zoom number. Scaling the area rule instead is a trap: it fetches a level COARSER and draws it bigger, which doubles labels just the same. |
| The renderer | ViewState::getRenderZoom, handed to vt::ViewState. vt sizes everything by 2^(zoom - tileZoom), so it has to be told the zoom the tiles were picked for. Miss this and labels are still double, with everything else already right. |
The arithmetic is all/native/graphics/ZoomConvention.h, header-only and covered by
tests/api/ZoomConventionTest.cpp - which asserts the traps above rather than only the happy path.
Two things the offset deliberately does not do. A raster source's 256-pixel tiles are drawn at
512 points and blur, exactly as they would in maplibre without tileSize: 256; set TileDrawSize
to compensate. And a style's zoom stops do not move: they are evaluated at the TILE zoom
(mapnikvt/TileReader.cpp), which the offset leaves alone, so a converted MapBox style is no more
and no less aligned than before.
TileDrawSize picks the tile, it does not magnify the style
The other way to adopt maplibre's convention is TileDrawSize = 512, which renumbers the camera
and moves the tile the layer picks - z13 tiles at view 13.2, as maplibre fetches them, where the
offset alone leaves the SDK on z14. It used to double every label and line with them: vt sizes a
style in fractions of the tile it was DECODED against, that was fixed at 256, and the tile was then
painted 512 dp wide. Measured in the preview grid at z13.51: a residential casing of 2.68 style px
drew 5.36 CSS px against maplibre's 2.68.
VectorTileLayer now hands TileDrawSize to the decoder beside the pixel scale, so a style pixel
is a dp whichever size is set. Both are read when the layer JOINS the map - set them before adding
it, and a later change reaches the camera but not the style.
Method: screenToMap at two points a known number of DEVICE pixels apart (the centre of the
drawing buffer maps exactly to focusPos, which is how the convention was confirmed), divided by
devicePixelRatio. Do not pan and read focusPos - kinetic pan keeps gliding after mouseup and
inflated the first measurement by 1.26x.
A drag that ends off the canvas
Mouse down is bound to the canvas, but move and up go to the document, and blur to the
window - maplibre's own arrangement, for the reason its handler_manager.ts gives: there is no
pointer capture to lean on, so a release outside the canvas only ever reaches a document-level
listener. Bound to the canvas, a drag that ended anywhere else never saw its mouseup, the map
stayed in the drag, and every later hover panned it.
Two things follow. The move handler must ignore everything unless a drag is actually running,
because it now sees the pointer crossing the whole page. And coordinates have to come from
clientX/clientY minus the canvas rect, not from targetX/targetY - those are relative to
whatever the listener was bound to, which is no longer the map.
A release outside the browser WINDOW may reach no listener at all (mapbox-gl-js#4622), and the
window keeps its focus, so blur does not fire either. So every move checks that the drag's own
button is still in MouseEvent.buttons, and ends the drag when it is not - maplibre's MouseMoveStateManager.isValidMoveEvent. Mind the bit order: button 2 (right) is
buttons bit 2, button 1 (middle) is bit 4. Like maplibre, a drag ends only on its own button's
mouseup, and a second button pressed mid-drag starts nothing.
Labels shredded by buildings from zoom 12
Not a glyph bug, though it looks like one: the text is drawn and then PAINTED OVER, so the letters
come out with pieces missing. It starts at zoom 12 because that is where OpenMapTiles begins
serving building.
Buildings default to RENDER_ORDER_LAST and are drawn after the flat label pass, by design. The
answer is not to move that order but to make the labels billboards - text-placement: billboard
for a point, billboard-line-repeat along a line - because the billboard pass is the one that runs
after the buildings (TileRenderer::onDrawFrame3D). The starters do that.
Nothing web-specific here; it is how the renderer is meant to be used, and the same style would behave the same way on a phone.
Two double-click zooms
The host listened for the browser's dblclick and zoomed. So did the SDK, from the pointer stream
it is already given: TouchHandler::doubleClick puts the map into SINGLE_POINTER_ZOOM, which is
BOTH the double-tap zoom and the double-tap-and-drag zoom, and on release it only adds the tap zoom
when the gesture did not actually drag (singlePointerZoomStop).
A double-tap-and-drag therefore zoomed continuously, and then zoomed once more when the button came
up - the browser's dblclick arriving on top of a gesture the SDK had already handled. There is no
dblclick listener now; the SDK owns the gesture.
A flight and a gesture fighting over the camera
TouchHandler cancelled the pan, rotation, tilt and zoom animations whenever the user touched the
map, but not a flight - stopFlight was called from nowhere except the facade method. So a
flyTo still in the air kept interpolating along its own path while the drag moved the camera too,
and the map flickered between the two: the flight's position, which near the start is still the
place you left, and the place you had just dragged to. Every platform, not only the web.
The fix is one more line at each of the eleven places the other four are stopped.
While there: use flyTo, not fitBounds, to travel. fitBounds moves through the pan and zoom
animations, whose duration is taken literally, so a fixed one drifts across a country at the same
rate it crosses a suburb. flyTo follows van Wijk's arc and, given a duration of 0, picks
S / 1.4 seconds itself - maplibre's rule, already implemented in AnimationHandler and easy to
override by accident. Paris to Grenoble comes out at 4.7 s.
Range requests, and why PMTiles failed on the web
A PMTiles archive is read by HTTP range, and HTTPClient checked the Content-Range of every 206
against the offset it asked for. A browser hides Content-Range from the page unless the server
sends Access-Control-Expose-Headers, and tile hosts generally do not - so the header read as
absent, the offset compared as 0, and every range request was rejected with
Content range mismatch: 0/127. The check now applies only when the header is actually readable:
the range was honoured either way, and refusing it makes every PMTiles archive unreadable in a
browser.
Mapterhorn's planet archive (https://download.mapterhorn.com/planet.pmtiles) is 705 GB of
Terrarium-coded WebP, z0-12, and serves access-control-allow-origin: * - only the ranges actually
read are fetched.
3D terrain, and what a desktop should set differently
?terrain=<url> attaches a DEM. Verified against Mapterhorn's planet archive and against AWS's
terrarium tiles; both read the same elevation, so Terrarium is the right decoder for Mapterhorn
(?demEncoding=mapbox switches to Terrain-RGB and is visibly wrong - the clearance shoves the
camera out to zoom 6).
Elevation comes back in PROJECTED units, not metres: Mont Blanc reads 6849 where the mountain is 4808, because the Mercator scale at 45.8 degrees is 1/cos = 1.435. 4808 x 1.435 = 6900. Nothing is wrong with the DEM when that number looks too big.
The bench sets two things a phone would not:
| Setting | Phone default | Web | Why |
|---|---|---|---|
autoFlattenTilt / autoFlattenParallax | 88 / 2 | 0 / 0 (off) | Auto-flatten drops the height field when the map looks straight down, to save a phone the cost. A desktop can hold it up, and dropping it every time the map returns to 88 degrees is a visible sink-and-rise |
meshResolution | 64 | 128 | Cells per tile edge, clamped to 2..256. 64 is a phone budget |
Tile LOD: maplibre's numbers, and why buildings stop when you flatten the tilt
setTileLODProfile(TILE_LOD_PROFILE_REFERENCE) - maplibre's and mapbox's own values, unmodified.
The obvious complaint about them is that a tilted view loses its 3D buildings partway out, and that it gets WORSE the flatter you tilt, which sounds backwards. It is not, and it is worth writing down because two plausible explanations are both wrong.
The LOD applies no tilt penalty. Measured at the reference values: cosThetaExponent is -0.306
at tilt 24, 32, 60 and 90, and uniformLevelDrop is 0 at every one of them - the tile-count cap is
inert here, exactly as its own doc says. Neither term moves with pitch.
The cliff is in the DATA. OpenMapTiles carries building from zoom 13 (transportation from 4,
water and place from 0). Flatten the tilt and the far ground covers very few pixels, so the
area rule correctly coarsens it - measured at tilt 18, the frame fetches z7 through z14. Every tile
below 13 has no building geometry at all, so instead of simpler buildings you get none, while the
roads and water keep drawing. The boundary moves toward the camera as you tilt down.
Buying it back means refining the far field, and that is expensive. Measured at 2.3376/48.8606,
zoom 15.5, tilt 30, reading TileLayer.visibleTileCount:
MaxZoomLevelsOnScreen | visible | preloading | vs the default |
|---|---|---|---|
| 9.314 (maplibre, shipped) | 44 | 7 | 1.0x |
| 7.5 | 107 | 14 | 2.4x |
| 6.0 | 259 | 18 | 5.9x |
Not worth it: the boundary moves a little, the tile count multiplies, and the map reads as less
smooth. What mapbox and maplibre actually do with that horizon is hide it behind fog, not buy
tiles for it - FogOptions is the lever, and it costs nothing per tile.
Measuring any of this needs a camera move. No LOD option is in MapRenderer's option-change
list, so changing one at runtime does not re-cull: the tile list, and the counter, stay as they
were until the camera actually moves. Three identical readings in a row are the symptom.
Draw distance
Options.drawDistance defaults to 16, which is a phone's battery talking: tilt the map and
buildings and terrain stop at a near band. The web host sets 96, because a desktop GPU can
afford what mapbox and maplibre draw - from Chamonix at tilt 35 that reaches Lausanne.
DPI
syncCanvasSize sets Options.DPI to Const::UNSCALED_DPI * devicePixelRatio, the same thing the
iOS host does with UIScreen's scale. Miss it and the SDK takes a 2x canvas for a 1x screen: every
tile is drawn at half the size it should be, so roughly four times as many are on screen at once and
the map looks tiny and loads slowly. It is set on every resize rather than once, so it follows a
window dragged to another display.
Fonts
A style that names a font it does not ship falls through to SystemFontUtils, which on the web
means /fonts/<name>.ttf in the virtual filesystem. web/fonts/ (Roboto, regular and bold) is
tracked and preloaded into every module; a generic name (sans-serif, Arial, Helvetica) or a
fallback lookup gets Roboto, and a bold style Roboto-Bold. Until that mapping existed, every style
naming sans-serif drew no labels on the web. web/demo/fonts/ (gitignored) adds faces to the
bench only.
The platform layer
web/native/ is the platform directory, the same shape as android/native and ios/native, and
CMake globs it under elseif(EMSCRIPTEN). There is no wrapper directory: the web build has no
SWIG bindings and is meant to be driven through the facade's C ABI.
| File | What it does |
|---|---|
network/HTTPClientEmscriptenImpl | A synchronous XMLHttpRequest, from a worker |
ui/WebMapView | The canvas host: WebGL 2 context, requestAnimationFrame, pointer/touch/wheel events |
utils/AssetUtils | Assets from the emscripten virtual filesystem, under /assets by default |
utils/SystemFontUtils | Fonts from /fonts — a browser has no font API to read outlines out of |
utils/PlatformUtils | PLATFORM_TYPE_WEB; the app identifier is location.origin, which is what a tile server checks |
utils/ThreadUtils, components/Task | Trivial; a web worker has no priority to set |
graphics/BitmapCanvasWebImpl | The browser's 2D canvas (OffscreenCanvas on a worker), for Text, BalloonPopup and celestial labels |
vt::parseFontNames gained a web platform tag, so a style can write web:Inter next to
android:Roboto.
The threading contract
The SDK's tile pools and its cull, label and billboard workers are real threads, so the build is
-pthread and the page must be cross-origin isolated — Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which is all web/demo/serve.py
adds over http.server. Without them the browser refuses SharedArrayBuffer and the module never
starts.
Two rules follow from that split, and both cost a debugging session to find:
- GL lives on the main thread only. The context is created there and every frame is drawn from
requestAnimationFrame. A GL call from a worker fails withGLctx is undefined. requestAnimationFrameis main-thread only too. The redraw request comes from the tile and label workers, soWebMapView::requestRedrawmarshals it withemscripten_async_run_in_main_runtime_thread. Calling it directly from a worker does nothing at all: the first frame draws, the flag stays set, and the map is frozen with tiles arriving behind it.
Tile threads
A tile task blocks on its fetch, network or cache, and then decodes, so Options::TileThreadPoolSize
is how many tiles load at once. The SDK's default, 2, is tangram's decode-thread count; maplibre
runs up to 16 requests. WebMapView sets 6: terrain-3d at the maplibre 3D-terrain example's
camera (47.28324, 11.39146 z12.05 tilt 25), 1200x800, a first 360 degree turn of 6 s with 64 MB
tile caches, a visible tile waited 7 ms median / 83 ms p90 in the queue with 2 threads and
1 / 22 ms with 6; culls with a blank tile 107 -> 86. An app's own tileThreadPoolSize set after
the map starts wins.
The allocator
The build links mimalloc (-sMALLOC=mimalloc). emscripten's default, dlmalloc, takes one global
lock for every malloc and free, and a tile decode is mostly small allocations: the decode
workers queue on it, and so does the main thread - which cannot block in the browser, so it
busy-waits (_emscripten_get_now on top of a main-thread profile) and the frame stalls with it.
Sparse tiles never show it; a city at z15 does.
Measured 2026-09-30, the style-parameters example (Massif streets, badge POIs, Grenoble z15.5)
and display-a-map (the same style at Mont Blanc z11), Chromium headless on Metal (M5 Pro), 8
pthreads, lite Release. "Filled" is the last frame of the load; the pan is three 600 px drags.
CPU comes from the DevTools sampling profiler on every worker, with idle futex waits removed.
| style-parameters | dlmalloc | mimalloc | mimalloc + -O2 hot libs |
|---|---|---|---|
| map filled | 6.1-6.5 s | 1.09-1.12 s | 1.00-1.02 s |
| worker CPU, load | 11.6-11.8 s | 1.8-1.9 s | 1.4-1.55 s |
| main thread busy, pan | 9.7-10.3 s | 3.9-4.6 s | 3.2 s |
| frame CPU p90, pan | 41-51 ms | 4.8-5.4 ms | 3.9-4.0 ms |
| frame interval p90, pan | 47-53 ms | 11.6-12.1 ms | 11.5-11.7 ms |
display-a-map fills in ~1.1 s with either allocator: city density is the trigger, the lock the
multiplier. Badge or plain POIs change the dlmalloc fill by nothing (6.2 vs 6.3 s), and
RelWithDebInfo fills no faster than Release under dlmalloc, so neither was the cause.
The cost: mimalloc adds 57 KB of .wasm (21 KB brotli) and grows the wasm heap from 144 MB to
170-205 MB for the same session. The -O2 hot libraries add 761 KB (146 KB brotli) for about 20%
less decode and frame CPU.
The same on the standard profile CI ships (1.68 -> 1.85 MB brotli), before and after both
changes, with the example also given terrain at tilt 45 (sun 225/30), then shadows 0.35:
standard | flat | terrain | terrain + shadows |
|---|---|---|---|
| map filled | 6.2 s -> 1.0 s | 14.4-18.4 s -> 1.4-1.6 s | never, see below |
| frame CPU p90, pan | 45-51 -> 4.1-4.9 ms | 56-62 -> 8.8-10.4 ms | 79-93 -> 11-15 ms |
| frame interval p90, pan | 48-54 -> 11.8 ms | 65-72 -> 9.7-11.7 ms | 85-98 -> 12.6-16.5 ms |
| wasm heap | 144 -> 171 MB | 297-358 -> 424-426 MB | 515-612 -> 512-611 MB |
With shadows on, the vector tiles are fetched and decoded in a loop on both builds - 699 to
8392 requests for 17-19 tiles - so the map never settles; the shadow pan numbers were taken with
that loop running. It is a separate bug from the allocator, and not web-only: day-cycle-light on
the Android emulator loaded its centre tile 353 times in 2.5 minutes, the iOS simulator 13 times.
Isolation on GitHub Pages
The docs site is on GitHub Pages, which serves no custom headers at all — so the threaded module
cannot start there. emscripten fails with
SharedArrayBuffer transfer requires self.crossOriginIsolated before a single tile is fetched.
web/demo/coi-serviceworker.js is the way around it: registered from the page, it re-serves every
response with the two headers and reloads once, which is what makes a static host isolated. COEP is
credentialless, not require-corp — a tile server sends no
Cross-Origin-Resource-Policy, and require-corp would block every tile.
Observed working in headless Chromium (2026-09-26): served with no headers, the example runner registered the worker, reloaded once under its control, came up isolated and drew. Reproduce it:
python3 web/demo/serve.py --no-headers # serve the way GitHub Pages does
The page should reload itself once and then render; crossOriginIsolated in the console tells you
which side of the fence you are on.
PTHREAD_POOL_SIZE=12 covers the pools plus the three workers. The pool is pre-warmed because
pthread_create on the main browser thread cannot block waiting for a worker to spawn.
A synchronous XMLHttpRequest is illegal on the main thread and legal on a worker, which is
exactly where the SDK does its blocking tile loads - so the arrangement above is what makes the
HTTP client work at all.
The stale heap view after a synchronous XHR
A worker that comes back from a synchronous XHR still sees the wasm memory at the size it had when
the request started: V8 refreshes a thread's view of a shared memory another thread grew only when
that thread handles an interrupt, and a blocking XHR handles none. emscripten_fetch then copied
the response through the stale view, RangeError: offset is out of bounds was thrown inside it,
and the tile failed to decode (and stayed failed for the failure marker's 30 s). Measured on the
WASMFS build, terrain-3d with 6 headless Chromiums in parallel: 5 runs in ~40 failed; the copy
target was at 42 MB through a view still 38 MB long, in a heap of 600+ MB. Reproduced alone with a
shared WebAssembly.Memory, a worker in a synchronous XHR and the main thread growing the memory:
memory.buffer.byteLength reads the old size after the XHR and the new one after memory.grow(0)
(Chrome 149, 3 of 3).
The client is therefore its own EM_JS XHR, not emscripten_fetch, and calls wasmMemory.grow(0)
before it touches the heap after the request (0 failures in 27 runs since). -sGROWABLE_ARRAYBUFFERS
would remove the class of bug, but WebGL refuses views of a resizable buffer (uniformMatrix4fv:
"The provided ArrayBuffer value must not be resizable"), which is why emscripten reverted its
default (#27260).
The filesystem is WASMFS
The build links -sWASMFS. With the legacy JS filesystem every file operation from a worker ran on
the main thread, queued behind the frame, and a persistent-cache source does a sqlite read and a
write per tile under one lock: terrain-3d at (7.45, 45.85) z9 tilt 35, 1920x1080, cold cache, the
DEM's HTTP request took 28 ms and the tile load around it 525 ms with 3 loader threads,
2 s with 16 - more threads, a longer queue. WASMFS keeps the files in wasm memory, where a worker
reads them itself: 31 ms a tile, the DEM loaded in 3.2 s instead of 26-36 s. The same view
uncached loads in 4.6-6.1 s with 3 threads or 16, so the thread count was never the limit.
WASMFS has no IndexedDB backend, and its OPFS backend cannot be created on the main thread, where a
source opens its database. persistDirectory(module, path) (web/js/massif.mjs) keeps a directory
in OPFS from JavaScript instead: restored once, written back every 10 s and on pagehide, whole
files whose mtime moved. A file deleted in the module is deleted in OPFS too - a stale sqlite
journal restored beside a newer database would roll it back - but only one this page wrote, so two
tabs do not delete each other's. The cache files count against the wasm heap, up to each cache's
capacity. A copy taken mid-transaction is the database plus its journal, which sqlite rolls back
on the next open.
The SDK module
Two executables link the same libmassif.a. massif-web (web/module/main.cpp) is what an app
loads: it draws nothing until the page asks for a map. massif-demo is the bench above.
| Export | What it is |
|---|---|
massifCreateMap(selector) | starts the map on a canvas; adopts nothing |
massifAdopt(kind, what, id) | MassifInterop::adopt for view, options or layers |
massifAttachMapEvents(handle) | createEventBridge, chaining the listener already set |
massifBridgeLayerClicks(handle) | the same for a vector or vector tile layer's clicks |
scripts/build-web.py writes the module to dist/web/; node web/package/build.mjs (after
npm run build in bindings/js) turns that directory into the @massif-maps/web package, and
--website copies it to website/static/massif/. Smoke pages: serve the repo root with
web/demo/serve.py --dir . and open /web/module/ (the low-level binding) or
/dist/web/examples/run.html?id=display-a-map (the typed API).
Variants
A release ships two modules side by side: standard as massif-web.*, and full as
massif-web-full.*, which a page picks with createMap(canvas, { variant: 'full' }):
python3 scripts/build-web.py --profile standard --configuration Release
python3 scripts/build-web.py --profile full --variant full --configuration Release
--variant sets the target's OUTPUT_NAME (SDK_WEB_MODULE_NAME) and builds in its own
build/web-<variant>. The name is fixed at link time, not by renaming files afterwards: the loader
carries the .wasm and .data names, and the pthread workers start from the loader's own URL.
| Module | .wasm | gzip | brotli |
|---|---|---|---|
massif-web (standard) | 6.43 MB | 2.48 MB | 1.85 MB |
massif-web-full (full) | 8.55 MB | 3.18 MB | 2.34 MB |
Measured 2026-09-30, Release. The npm tarball grows from about 3 MB to 6.4 MB; a page still
downloads one module. lite is 1.53 MB brotli, too close to standard to ship as a third one.
- The typed API is the NativeScript plugin's, moved to
bindings/js(@massif-maps/api) with the platform behind aNativeBridge.web/js/bridge.mjsis the web's: the C ABI, with the view, options and layers as tokensmassifAdoptresolves.web/js/index.mjsiscreateMap. - Every event is delivered on the page's thread. A JavaScript handler is a slot in the page's
wasm table, which the pthread workers do not have, so "origin" delivery is refused and
canConsumeis false. - One map per module, and one module per page for the typed API (its bridge is a module-level
value). emscripten keeps one callback per event target, so a second
WebMapViewwould take the document's pointer events from the first. The site's examples run one per iframe. - No destroy.
WebMapView's destructor leaves the document and window callbacks pointing at it, so a map lives as long as its page.
The JavaScript binding
web/js/massif.mjs wraps the facade's C ABI. It is deliberately thin - the facade is a table, so a
new SDK feature reaches JavaScript without touching the binding:
const massif = await loadMassif(); // massif-web.mjs beside massif.mjs
const { layers, camera } = massif.createMap(document.getElementById('map')); // adopts under 'map'
camera.flyTo([[2.35, 48.86], 15, 0, 90, 0, 1.5]);
massif.call(camera.handle, 'screenToMap', [100, 200]);
What it has to get right, and each was a bug first:
-sEXPORTED_FUNCTIONSREPLACES the default list, so_mainhas to be in it or the program is stripped and nothing runs at all. The list is generated fromMassifApiC.hby CMake, so a newmm_function reaches JavaScript by existing.- The host has to adopt its map -
MassifInterop::adopt("map", "map", view)- or the camera has nothing to point at.createMapdoes it; the bench adopts inmain(). await Module(...)resolves beforemain()has run when pthreads are on, so reading the bench's map straight away is a race.MassifCamera.attachpolls for it.mm_callanswers with a result HANDLE, not a string buffer. Read it like any other object and release it withmm_destroy_handle, or the context holds it forever. Reading it as a string buffer silently returned garbage for every method that produces a value.- Out-parameters must be zeroed first.
_mallocdoes not, so a void method's result length came back as whatever was in that memory - 4, which then parsed as a control character.
bindings/typescript/massif.d.ts describes what can be passed; it is generated by
scripts/gen-api-bindings.sh.
What the build does not carry
- Routing, geocoding and offline packages. The release and the site build the
standardprofile: sqlite (so the persistent tile cache, kept in OPFS) but no Valhalla, no geocoder and no package manager.--profile fullbuilds and runs; nothing ships it. - Shadows and the sky are barely tested here. 3D terrain, the depth pre-pass and a post-process effect are exercised by the peak finder example; shadows are not. Raster tiles, MVT decoded through mapnikvt and styled by CartoCSS, and labels with halos and accented glyphs all render — that part is observed, not inferred.
The style preview on the documentation site
/preview on the site is this module with a React page around it. Two things are worth knowing.
locateFile has to be explicit. Emscripten's fallback resolves the .wasm and the preloaded
.data against the DOCUMENT, and the page sits at a different depth in the two places it runs:
/preview/ on the dev server, /preview.html once built. The built one asked for
/massif-demo.data, one directory too high - a 404 that appeared only in production, which is
exactly the kind that reaches a user first.
A rebuilt wasm needs a no-store dev server. Its URL never changes, so a browser will happily
run yesterday's renderer against today's page - which reads exactly like "my change did nothing".
website/plugins/style-preview sends Cache-Control: no-store for that reason. Cost an afternoon
once: an A/B that appeared to disprove a fix was measuring the cached build.
Where the wasm comes from. It is a build artefact and is not tracked. .github/workflows/web-preview.yml
builds it whenever all/native, libs-massif, web/ or the build scripts change on master, and
uploads it as the web-preview artefact; docs.yml downloads the newest successful one into
website/static/preview before building the site. That download is best-effort — without it the
page says the module is missing and the rest of the site deploys unaffected. It is a separate
workflow because docs.yml also runs on every documentation push and nightly for the roadmap page,
and an hour of emscripten per typo is not a trade worth making.
The page adds no C++. It runs the same web/demo module and drives it entirely through the
facade's C ABI: clear the layer list, build a layer from a spec, add it. The one thing that had
to reach the facade for it is addFallbackFont on the style kind — a decoder built from a spec
had no way to be given font bytes, so every converted MapBox style, which all name DIN Pro, lost
its labels. web/demo/main.cpp also adopts its Layers now, which is what makes the map
replaceable at all.
MapBox style JSON is translated in the page, by mapbox2css — the CLI's own TypeScript, which runs
in a browser unchanged apart from sprites. sprite.ts used to reach straight for node:fs and
pngjs; it goes through a SpriteHost now, and the browser one fetches the sheets and uses the
exact PNG codec in png.ts. That codec exists because the obvious route — createImageBitmap into
a 2D canvas — stores premultiplied colour, so reading it back changes every partly transparent
texel. On an SDF sheet, whose alpha channel is a distance field and not opacity, that is not a
rounding error but a mangled icon.
What emcc found that no other build did
stdext's utf8_filesystem::fseek64, ftell64 and ftruncate64 had a branch for Windows,
Android and Apple and no #else — so on emscripten the functions had no body at all. That is
undefined behaviour, and at -O2 LLVM folded it backwards through the caller: URLFileLoader
reported that it could not open the file without ever calling fopen, so every file:// read on
the web failed identically whether the file existed or not. The fix is a generic POSIX branch
(fseeko/ftello/ftruncate), which is what Apple was already using.
Beyond that, the two latent portability bugs (stdext's unistring, harfbuzz's
-Wunused) were already fixed for
the style tools' wasm build, which is what proved
vt, mapnikvt, cartocss, freetype and harfbuzz compile under emcc in the first place. What was
new here is vt's GL renderer: the style tools build it with EXCLUDE_GL=ON, so GLTileRenderer
had never seen emscripten until this build. It compiled unchanged.
Two dependencies needed wiring rather than fixing: zlib comes from libs-external (there is no
system one, same as UWP), and pion is dropped (it is boost::asio sockets, which a browser has
none of).