Skip to main content

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):

ArtefactReleaseRelWithDebInfo
.wasm5.78 MB354 MB
.wasm gzipped2.19 MB—
.wasm brotli1.61 MB—
.mjs loader265 KB429 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:

.wasmgzippedbrotli
-Oz, as shipped4 687 9261 823 1101 367 486
+ -flto=thin4 800 1681 845 5411 385 803
+ -flto (full)4 978 6201 877 0091 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.

ParameterMeaning
lon, lat, zoomThe camera. Defaults to Paris at z12
sourceTile URL template. A .png/.jpg/.jpeg/.webp in it means raster, anything else vector
cssURL-encoded CartoCSS, used when the source is vector
projectA CartoCSS project directory under web/demo/styles, preloaded like the fonts
styleWhich entry file of that project to use, without its extension. Default project
terrainA DEM tile URL or .pmtiles archive; turns on 3D terrain
terrainMaxZoomDeepest 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:

InputWhat it doesConstant
DragPanthe SDK's own
WheelZoom toward the pointer1/450, sigmoid 2/(1+exp(-abs(Δ·rate)))
Trackpad pinch (wheel + ctrl)Zoom toward the pointer1/100
Right-drag, or ctrl-dragRotate and tilt; about the ground under the pointer with Options.OrbitAroundPivot0.8 and 0.5 degrees per CSS pixel
Double-clickZoom in 1, shift for out, toward the pointer0.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 cameraViewState::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 levelTileLayer'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 rendererViewState::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:

SettingPhone defaultWebWhy
autoFlattenTilt / autoFlattenParallax88 / 20 / 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
meshResolution64128Cells 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:

MaxZoomLevelsOnScreenvisiblepreloadingvs the default
9.314 (maplibre, shipped)4471.0x
7.5107142.4x
6.0259185.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.

FileWhat it does
network/HTTPClientEmscriptenImplA synchronous XMLHttpRequest, from a worker
ui/WebMapViewThe canvas host: WebGL 2 context, requestAnimationFrame, pointer/touch/wheel events
utils/AssetUtilsAssets from the emscripten virtual filesystem, under /assets by default
utils/SystemFontUtilsFonts from /fonts — a browser has no font API to read outlines out of
utils/PlatformUtilsPLATFORM_TYPE_WEB; the app identifier is location.origin, which is what a tile server checks
utils/ThreadUtils, components/TaskTrivial; a web worker has no priority to set
graphics/BitmapCanvasWebImplThe 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 with GLctx is undefined.
  • requestAnimationFrame is main-thread only too. The redraw request comes from the tile and label workers, so WebMapView::requestRedraw marshals it with emscripten_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-parametersdlmallocmimallocmimalloc + -O2 hot libs
map filled6.1-6.5 s1.09-1.12 s1.00-1.02 s
worker CPU, load11.6-11.8 s1.8-1.9 s1.4-1.55 s
main thread busy, pan9.7-10.3 s3.9-4.6 s3.2 s
frame CPU p90, pan41-51 ms4.8-5.4 ms3.9-4.0 ms
frame interval p90, pan47-53 ms11.6-12.1 ms11.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:

standardflatterrainterrain + shadows
map filled6.2 s -> 1.0 s14.4-18.4 s -> 1.4-1.6 snever, see below
frame CPU p90, pan45-51 -> 4.1-4.9 ms56-62 -> 8.8-10.4 ms79-93 -> 11-15 ms
frame interval p90, pan48-54 -> 11.8 ms65-72 -> 9.7-11.7 ms85-98 -> 12.6-16.5 ms
wasm heap144 -> 171 MB297-358 -> 424-426 MB515-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.

ExportWhat 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.wasmgzipbrotli
massif-web (standard)6.43 MB2.48 MB1.85 MB
massif-web-full (full)8.55 MB3.18 MB2.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 a NativeBridge. web/js/bridge.mjs is the web's: the C ABI, with the view, options and layers as tokens massifAdopt resolves. web/js/index.mjs is createMap.
  • 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 canConsume is 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 WebMapView would 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_FUNCTIONS REPLACES the default list, so _main has to be in it or the program is stripped and nothing runs at all. The list is generated from MassifApiC.h by CMake, so a new mm_ 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. createMap does it; the bench adopts in main().
  • await Module(...) resolves before main() has run when pthreads are on, so reading the bench's map straight away is a race. MassifCamera.attach polls for it.
  • mm_call answers with a result HANDLE, not a string buffer. Read it like any other object and release it with mm_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. _malloc does 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 standard profile: sqlite (so the persistent tile cache, kept in OPFS) but no Valhalla, no geocoder and no package manager. --profile full builds 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).