cf.completefrontend
My progress
SYSTEM DESIGN · #118

Design Google Maps

The web app at google.com/maps: a map of the whole planet you can pan, zoom, rotate, and tilt, with place search, a details panel, and driving directions with live traffic. The hard parts are turning a sphere into square tiles the browser can fetch and cache, drawing them at 60 frames per second while the user drags and pinches, showing thousands of markers without melting the page, and keeping the URL, the side panel, and the map in agreement.

The question

Design the web version of Google Maps. A user opens google.com/maps, sees a map of the area around them, drags and zooms it, searches for a place, opens its details, and asks for directions. The map shows live traffic, and every view can be shared as a link.

Focus on the front end. Walk me through how the map itself is drawn and fetched, how panning and zooming stay smooth, how you show many places on the map at once, how search and directions work with the map, and how you keep it fast and usable on a mid-range laptop and a phone browser.

Real-life examples: Google Maps on the web (google.com/maps), Apple Maps on the web, OpenStreetMap with MapLibre or Leaflet, Bing Maps, Store locators and delivery maps built on a maps SDK

Requirements: what to ask before drawing anything

Google Maps is three products in one window: a map engine (the whole planet, draggable, zoomable, rotatable), a search product (find a place, see a list, open its details), and a routing product (A to B with live traffic). The interviewer wants to see that you can scope all three and go deep on the map engine, the part no other question covers.

Spend the first five to seven minutes on questions whose answers change the design. The table lists the questions this guide asks, the answer it assumes, and what each answer changes.

QuestionAnswer we assumeWhat it changes
Do we build the map renderer or use a maps SDK?We build it. The interviewer wants the tiles, the rendering, and the gestures, not a call to a library.Most of the guide is about the map engine: projection, tiles, the WebGL draw loop, and input.
Which devices?Desktop browsers first (a 1440 by 900 window on a mid-range laptop), and phone browsers second. Native apps are out of scope.Mouse wheel, trackpad pinch, and touch pinch all need to work. The budget is 60 frames per second on a laptop with integrated graphics.
Which features?Browse the map, search places with autocomplete, a place details panel, directions with alternatives and live traffic, and shareable links.Four big features sharing one map and one URL. Each one is a separate code chunk loaded on demand.
How fresh must the map be?Roads and labels change over weeks. Traffic changes every minute or two.Base map tiles can be cached for days. Traffic is a separate layer with a short cache life.
How many markers at once?Search returns 20 places. A layer such as "all EV chargers" or a user's saved places can have 10,000 or more.We need clustering and canvas-drawn markers, not one DOM node per place.
Do links need to restore the exact view?Yes. A link must reopen the same position, zoom, open place, and route.The URL is the source of truth for the view, and the app must boot from it.
Offline?Offline areas exist in the mobile apps. On the web we only survive short drops in connection.No bulk tile download on the web. A small cache for what the user already saw, and honest error states.
Which languages and regions?Every country. Labels in local scripts, right-to-left languages, kilometres and miles.Text is drawn by our renderer, so we must handle fonts, scripts, and direction ourselves.

Functional requirements

  • Show a map of the world that opens at the user's area (or the place in the link) and can be panned, zoomed with wheel, trackpad, pinch, double click, and keyboard, rotated, and tilted.
  • Show layers on top of the base map: live traffic colors, search result markers, a route, the user's location dot.
  • Search places with an autocomplete box biased to what is on screen. Show results as a list in a side panel and as markers on the map, kept in sync. Offer "Search this area" after the user moves the map.
  • Open a place: name, rating, hours, address, photos, reviews, and actions (directions, save, share).
  • Get directions between two or more points for driving, transit, walking, and cycling, with two or three alternative routes, travel times with traffic, the route drawn on the map, and a step by step list.
  • Every state worth sharing (view, place, route) lives in the URL. Back and forward move between meaningful states, not every pixel of a drag.

Non-functional requirements, with numbers

  • Smooth interaction: 60 frames per second while panning and zooming on a mid-range laptop. That leaves about 16 ms per frame for input, layout, our draw calls, and the GPU. On a 120 Hz screen the budget is about 8 ms.
  • Fast first view: something map-like on screen in under 1 second on a good connection, and a fully interactive map in under 3 seconds on a mid-range phone. Largest Contentful Paint under 2.5 seconds.
  • Light on the network: a typical city view at zoom 14 should need about 12 to 35 tiles. Repeat visits and panning back should hit a cache, not the network.
  • Bounded memory: the tile cache and GPU textures stay under a fixed budget (for example 200 MB of GPU memory and a few hundred decoded tiles), however long the user pans.
  • Correct under racing: fast typing, fast panning, and fast zooming must never show stale results for an old query or an old area.
  • Accessible: everything the map shows (results, place details, directions) is also available as text in the side panel, and the map itself works with a keyboard.

Out of scope, and why

We leave out the servers that build tiles from raw map data, the routing algorithm itself, ranking of search results, Street View imagery capture, and the native apps. We touch Street View only as a heavy feature that must be loaded lazily, and offline maps only to say what the web can and cannot do. Saying this out loud saves time: the interviewer can pull any of these back in if they care.

Check your understanding · Exercise 1

Which of these answers would change the front-end design the most? Select all that apply.

What makes a map app hard

Before picking any technology, name the properties of the problem that every later decision serves. A map is not a page with a picture on it. Five things set it apart.

  1. The content is the whole planet. The data behind a world map is many terabytes. The browser can only ever hold a tiny window of it, so the world must be cut into small pieces that can be fetched, cached, and thrown away independently. That is what tiles are.
  2. The content is continuous in space and in scale. A user can be anywhere, at any zoom from a whole continent to a single building, including the in-between zooms of an animation. Every frame must show something, even while the right pieces are still loading.
  3. Interaction is direct manipulation. The map must stick to the finger or the mouse. If a frame takes 40 ms instead of 16 ms, the map visibly lags behind the pointer, and users notice that more than a slow network.
  4. Many layers share one surface. Base map, labels, traffic, a route, search markers, the blue location dot, and popups are all drawn in the same space and must agree on where every coordinate is on screen.
  5. Three views of the same state. The URL, the side panel, and the map all show the current place, results, and route. If they ever disagree (the list shows Paris results while the map shows Lyon) the app feels broken.

Tiles answer the first, placeholders the second, the gesture design the third, layers the fourth, and URL state with one store the fifth.

The main screen

The wireframe names the parts of the screen the rest of the guide designs. On desktop the side panel sits on the left and holds search results, a place, or directions, and the map fills the rest. Map controls (zoom buttons, compass, layer switcher, my location) float in the corners, and the search box floats over the top left. On a phone the side panel becomes a bottom sheet with three heights: peeking, half, and full.

Wireframe of google.com/maps on desktop. Top left, a floating search box. Below it on the left, the side panel lists three results: 1. Boulangerie Paul, 2. Le Fournil, 3. Pain and Co, and can also hold a place or directions. The rest of the window is the map canvas drawn with WebGL, with numbered result markers, a Search this area button at the top, zoom plus and minus buttons at the bottom right, and a layers button at the bottom left.google.com/mapsSearch Google Mapsside panel1. Boulangerie2. Le Fournil3. Pain & Coresults, place, or routeSearch this area123map canvas (WebGL)+-Layers
Two views of the same state: the side panel shows results as text and the map shows them as markers. The floating controls are ordinary HTML buttons over the WebGL canvas.

Think first

When the user searches for "pizza", should the map move to fit the results, or should the results fit the map?

From a round planet to square tiles

The screen is flat and the Earth is not, so the first decision is a projection: a formula that turns a latitude and longitude into an x and y on a flat plane. Then we cut that plane into small squares, at many zoom levels, so the browser only fetches the squares it can see. Both choices are old and settled; the point in an interview is to show you understand the math well enough to compute which tiles a view needs.

Web Mercator: the projection every web map uses

At street level almost every web map (Google, Apple, Bing, OpenStreetMap, Mapbox) uses Web Mercator, registered as EPSG:3857. It treats the Earth as a sphere and stretches it into a square. Its key property is that it is conformal: it keeps angles and local shapes right, so a street corner that is 90 degrees on the ground is 90 degrees on screen, and north is always straight up. That is exactly what you want for navigation.

The price is that area is distorted: things far from the equator look much bigger than they are, which is why Greenland looks about the size of Africa. For that reason some maps, including Google Maps on desktop and Mapbox, switch to a 3D globe when zoomed far out. The tiles underneath are still the Web Mercator tiles described here.

Mercator stretches to infinity at the poles, so Web Mercator cuts the map off at about 85.0511 degrees north and south. That odd number is not arbitrary: it is the latitude at which the map becomes an exact square, so the whole world fits into one square tile at zoom 0.

Web Mercator projection. On the left, the Earth as a sphere with positions in latitude and longitude. An arrow labelled project leads to a square on the right, with (0, 0) at the top left and (1, 1) at the bottom right, cut off at 85.05 degrees north and south. Horizontal latitude lines inside the square are at the equator in the middle, 45 degrees north and south a little away from it, and 75 degrees north and south much farther, showing that equal steps of latitude take more space near the poles. Longitude maps to x in a straight line.spherelat, lng in degreesproject(0, 0)(1, 1)85.05° N75° N45° Nequator45° S75° S
The 30 degrees from 45 to 75 take more room than the 45 from the equator to 45: that is Mercator stretching the north. Longitude is linear, x = (lng + 180) / 360; latitude goes through the log formula.

The formulas, for longitude lng and latitude lat in degrees, give a position in the unit square from 0 to 1, with (0, 0) at the top left (180 degrees west, 85.05 degrees north):

ts
const TILE = 256;

// Unit square: x and y from 0 to 1, origin top left.
function project(lng: number, lat: number) {
  const x = (lng + 180) / 360;
  const r = (Math.max(-85.0511, Math.min(85.0511, lat)) * Math.PI) / 180;
  const y = (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2;
  return { x, y };
}

// World pixels at a (possibly fractional) zoom.
function toWorldPixels(lng: number, lat: number, zoom: number) {
  const { x, y } = project(lng, lat);
  const size = TILE * 2 ** zoom; // the world is this many pixels wide
  return { px: x * size, py: y * size };
}

function tileFor(lng: number, lat: number, z: number) {
  const { px, py } = toWorldPixels(lng, lat, z);
  return { z, x: Math.floor(px / TILE), y: Math.floor(py / TILE), inX: px % TILE, inY: py % TILE };
}
Longitude maps linearly to x. Latitude goes through the Mercator formula, so y grows faster near the poles.

The z/x/y tile pyramid

Now cut the square into tiles. At zoom 0 the whole world is one 256 by 256 pixel tile. At zoom 1 the world is 512 pixels wide and cut into 2 by 2 tiles, and each further level again doubles the width and splits every tile into four children. At zoom z there are 2^z tiles across and 4^z tiles in total.

A tile is named by three numbers: z for zoom, x for the column counted from the left, and y for the row counted from the top. That gives the familiar URL pattern /tiles/{z}/{x}/{y}, the scheme Google Maps made standard and OpenStreetMap documents as "slippy map" tile names.

The z/x/y tile pyramid. Zoom 0 is one tile, 0/0/0. Zoom 1 is a 2 by 2 grid of tiles named by x and y from 0,0 to 1,1. Zoom 2 is a 4 by 4 grid from 0,0 to 3,3. Tile 2, 1 at zoom 2 is highlighted, with its parent 1, 0 at zoom 1 and the root tile at zoom 0. A parent is found by halving x and y and rounding down.each level splits every tile into four0,00,01,00,11,10,01,02,03,00,11,12,13,10,21,22,23,20,31,32,33,3z0: 1 tilez1: 4 tilesz2: 16 tilesparent of (z, x, y) = (z - 1, floor(x / 2), floor(y / 2))
Every level doubles the width and splits each tile into four. Going up is just halving: tile 2/2/1 lives inside 1/1/0, which lives inside 0/0/0. That chain is what the renderer walks to find a placeholder.
Metres per pixel shrink by half per zoom level. Away from the equator multiply by cos(latitude): at Paris (48.9 degrees) it is about 0.66 times the equator value.
ZoomTiles in the worldMetres per pixel at the equatorWhat you see
01about 156,543The whole world in one tile
51,024about 4,892A large country
10about 1 millionabout 153A metro area
14about 268 millionabout 9.6Neighbourhood streets
18about 69 billionabout 0.6Buildings and house numbers
21about 4.4 trillionabout 0.07Close to the deepest Google Maps zoom in cities

Nobody stores 4.4 trillion tiles. Most of the world is ocean or empty land, and servers either skip empty tiles or render deep tiles on demand and cache them. On the client the big number teaches one thing: we can never enumerate the world, only compute the handful of tiles in view.

Worked example: which tile holds the Eiffel Tower?

The Eiffel Tower is at latitude 48.8584, longitude 2.2945. At zoom 15 the world is 256 * 2^15 = 8,388,608 pixels wide, and the code above gives world pixels of about (4,247,770, 2,885,856). Dividing by 256 gives tile x = 16,592 and tile y = 11,272, with the tower at about pixel (218, 224) inside that tile, near its bottom right corner. So the browser fetches /tiles/15/16592/11272, where one pixel is about 3.1 metres on the ground.

To find every tile the screen needs, project the center to world pixels, subtract and add half the viewport, and list every tile index between those corners. Longitude wraps around (tile x of -1 is the last column), latitude does not (rows outside 0 to 2^z - 1 are simply empty).

ts
function visibleTiles(center: { lng: number; lat: number }, zoom: number, w: number, h: number) {
  const z = Math.floor(zoom);           // fetch tiles for the integer zoom
  const scale = 2 ** (zoom - z);         // and stretch them by the remainder
  const { px, py } = toWorldPixels(center.lng, center.lat, z);
  const halfW = w / 2 / scale, halfH = h / 2 / scale;
  const n = 2 ** z, tiles = [];
  for (let y = Math.floor((py - halfH) / TILE); y <= Math.floor((py + halfH) / TILE); y++) {
    if (y < 0 || y >= n) continue;       // nothing above or below the world
    for (let x = Math.floor((px - halfW) / TILE); x <= Math.floor((px + halfW) / TILE); x++) {
      tiles.push({ z, x: ((x % n) + n) % n, y }); // wrap around the date line
    }
  }
  return tiles;
}
A fractional zoom such as 14.6 draws zoom 14 tiles scaled by 2^0.6, about 1.52. This is how smooth zoom works between levels.

256 or 512 pixel tiles?

Decision How big should one tile be?

256 by 256 pixels

The classic size from the first Google Maps in 2005. Google's Maps JavaScript API and OpenStreetMap raster tiles still use it as the default.

Pros

  • Small files, so the first tiles of a view arrive quickly.
  • Fine-grained: panning a little loads only a thin strip of new tiles.
  • Every tool and tile server understands it.

Cons

  • Many requests: a 1440 by 900 window needs up to 7 by 5 = 35 tiles.
  • Each tile has fixed overhead: headers, a decode, a GPU upload, and for vector tiles some duplicated data at the edges.

512 by 512 pixels

Each tile covers the area of four 256 tiles. Mapbox and MapLibre vector styles use 512 by default, which is why their zoom numbers are one lower than Google's for the same scale.

Pros

  • About a third as many requests: the same window needs up to 4 by 3 = 12 tiles.
  • Vector tiles compress better when larger, and labels are cut at tile edges less often.

Cons

  • Each tile is bigger and slower to arrive, so the first paint can be later on a slow network.
  • More wasted bytes at the edges of the view.

What we pick: For vector tiles we pick 512: fewer requests, better compression, and fewer cut labels, and HTTP/2 or HTTP/3 removes most of the cost of parallel requests anyway. For raster fallback tiles we keep 256 CSS pixels, but serve them at 2x resolution (512 actual pixels) on high density screens. We keep zoom numbers on the 256 convention, the one Google's URLs use, so a view at zoom 15 draws 512 pixel vector tiles from level 14.

Check your understanding · Exercise 2

A user is at zoom 12 and zooms in to exactly zoom 13 without moving the center. Which statements are true? Select all that apply.

Raster or vector tiles

A tile can hold a finished picture or the raw shapes to draw one. This is the biggest decision in the map engine, because it decides what runs on the client, how big the downloads are, and whether the map can rotate, tilt, and change style without a refetch.

The naive design is raster tiles: the server renders each tile to a PNG or WebP image, and the client places images in a grid. This is how Google Maps on the web worked from 2005 until its 2013 redesign, and how Leaflet with OpenStreetMap tiles still works.

Now watch it break. Rotate the map 30 degrees and every label tilts, because the text is baked into the pixels, and dark mode means fetching every tile again. Zoom from 14 to 14.5 and the images stretch 1.4 times, so text blurs. A 2x screen needs a second set of tiles with four times the pixels.

Decision What should a tile contain?

Raster tiles (images)

The server draws roads, areas, and labels into a 256 or 512 pixel image per tile, per style, per pixel density.

The client positions img elements or textures in a grid.

Pros

  • Trivial client: no geometry, no text layout, no WebGL needed.
  • Works on any browser and very old devices.
  • Exact look, decided once on the server.

Cons

  • Labels are baked in: no rotation, no tilt, blurry text between zoom levels, and labels cut at tile edges.
  • Every style, language, and pixel density is a separate set of tiles: more server storage and fewer cache hits.
  • Images of busy areas are often larger than the shapes that produced them.

Vector tiles (geometry)

The server sends the shapes in each tile: road lines, building polygons, label points with names, each with properties such as class: motorway.

The client styles and draws them with WebGL, and places the labels itself.

Pros

  • One tile serves every style, language choice, and screen density.
  • Smooth fractional zoom, rotation, and 3D tilt with crisp text at every angle.
  • Labels can be placed across tile edges and kept upright.
  • Smaller downloads per tile in most areas.

Cons

  • A heavy client: a WebGL renderer, a text shaper, a label collision engine, and a style engine, often 200 KB or more of compressed JavaScript.
  • CPU work to decode and prepare each tile, best done in a Web Worker.
  • Needs WebGL, and old or low-end GPUs need a fallback.

Hybrid: vector base, raster for some layers

Vector tiles for roads, areas, and labels. Raster tiles for satellite imagery, terrain shading, and as a fallback when WebGL is not available.

Pros

  • Satellite photos are images by nature, so they stay images.
  • A fallback path for devices where WebGL fails.

Cons

  • Two tile pipelines to build and test.

What we pick: We pick the hybrid: vector tiles for the map, raster for satellite and as a fallback. This matches what Google did: its 2013 redesign of google.com/maps moved the web map to vector data drawn with WebGL, with a raster "Lite mode" for browsers and machines without it. Its Maps JavaScript API for developers still defaults to raster tiles but has offered WebGL vector maps with tilt and rotation, generally available since 2022. Satellite view remains imagery.

Raster tiles compared with vector tiles. Raster: the server draws pixels, one image per style and density; the client places images in a grid with labels baked in; rotating tilts the text and dark mode means refetching every tile. Vector: the server sends shapes such as roads, areas, and label points; the client draws them with WebGL and a style and places labels itself; rotating keeps text upright and dark mode is only a new style.raster tileserver draws pixelsone image per style, densityimg in a gridlabels baked inrotate: text tiltsdark mode: refetch allvector tileserver sends shapesroads, areas, label pointsWebGL + styleclient places labelsrotate: text uprightdark mode: new style only
The difference is where the drawing happens. Raster finishes the picture on the server, so every change of look is a new download. Vector ships the ingredients and finishes on the client, so the look can change every frame.

Inside a vector tile

Google's tile format is private, so we design with the open standard, the Mapbox Vector Tile (MVT) specification, used by Mapbox, MapLibre, Esri, and many others. An MVT tile is a Protocol Buffers message (a compact binary encoding) with named layers such as water, roads, buildings, and place_labels. Each layer holds features, and each feature has a geometry type (point, line, or polygon), a geometry, and properties.

  • Tile-local coordinates. Positions are integers inside the tile on a grid called the extent, 4096 by default. That is 16 grid units per pixel of a 256 pixel tile, plenty of precision, and small integers compress well.
  • Commands and deltas. A geometry is a list of commands (MoveTo, LineTo, ClosePath) and each point is stored as the difference from the previous one, zigzag encoded so small negative numbers stay small. A road of 50 points that wanders a few units at a time costs a few bytes per point.
  • Shared strings. Property keys and values are stored once per layer and referenced by index, so motorway appears once even if 300 roads use it.
  • Buffers. Tile builders usually include a little geometry beyond the tile's edge (a buffer, often a few dozen to a few hundred units; the spec allows it but does not fix a size) so thick lines and polygons join cleanly where tiles meet.

Servers also simplify per zoom: a zoom 8 tile holds motorways but not residential streets, and coarser coastlines. That keeps each tile in the tens of kilobytes whatever the zoom, so the cost of a view stays about constant.

Styling on the client

With raw shapes on the client, a style decides the look: layers, each with a filter on feature properties and paint rules that can depend on zoom. Changing the style changes the map instantly with no new tiles. That is how dark mode and transit emphasis work.

json
{
  "id": "roads-major",
  "source-layer": "roads",
  "filter": ["in", ["get", "class"], ["literal", ["motorway", "trunk"]]],
  "paint": {
    "line-color": "#f6c56a",
    "line-width": ["interpolate", ["exponential", 1.5], ["zoom"], 6, 0.5, 18, 24]
  }
}
A style layer in the MapLibre style format. The line width grows smoothly with zoom, so it looks right at 14.3 as well as 14.

Label placement and collision

Labels are the hardest part of a vector map, and the part raster tiles hid from us. The client must decide, for every frame of an animation, which names to show. The usual approach:

  1. Collect label candidates from all visible tiles: city names, road names along their lines, shop icons with names. Each has a priority (a capital city beats a village, a motorway beats an alley).
  2. Sort by priority. For each candidate, compute its box (or a chain of small boxes for text that bends along a road) in screen space.
  3. Check the box against a grid index of boxes already placed. The screen is divided into cells of, say, 32 by 32 pixels, and each placed box is recorded in the cells it covers, so a check only looks at a few neighbours, not every label.
  4. If it does not collide, place it and add it to the grid. If it does, hide it. Fade labels in and out over 200 to 300 ms so they do not flicker during a zoom.

Because placement happens in screen space, text stays upright under rotation and labels can span tile edges. The cost is CPU time, so renderers rerun placement only every few hundred milliseconds during motion, or when the camera stops, and reuse the last answer in between.

Check your understanding · Exercise 3

A product manager wants a "dark mode" map and a map that can be rotated with two fingers. Which statements are true? Select all that apply.

Rendering: WebGL, layers, and a tile cache

With vector tiles chosen, the client has to turn shapes into pixels 60 times a second. Three questions follow: what draws (DOM, 2D canvas, or WebGL), how the layers stack, and how tiles flow from the network to the GPU and back out of memory.

Decision What should draw the base map?

DOM elements or SVG

Each tile is an img, or each road an SVG path, positioned with CSS transforms.

Pros

  • Easy to build, inspect, and style with CSS.
  • Fine for raster tiles: Leaflet does exactly this.

Cons

  • A vector tile has thousands of shapes; tens of thousands of SVG nodes make layout and paint far too slow for 60 frames per second.
  • No 3D tilt in a practical way.

Canvas 2D

One canvas, and every frame we call moveTo, lineTo, fill, and fillText for each shape.

Pros

  • Simple API, good text drawing, works everywhere.

Cons

  • All the drawing runs on the CPU, shape by shape, every frame. A dense city view at 2x density means millions of pixels and tens of thousands of path calls: it misses frames on a laptop.
  • Tilt needs a real 3D projection, which Canvas 2D does not have.

WebGL (or WebGPU later)

Tiles are converted once into triangle and line buffers on the GPU. Each frame we only update a camera matrix and issue a few draw calls per layer.

Pros

  • The GPU redraws hundreds of thousands of triangles per frame cheaply, so panning costs almost nothing on the CPU.
  • Rotation and tilt are just a different camera matrix.

Cons

  • Much more code: shaders, buffers, text drawn from glyph atlases, and handling a lost GPU context.
  • Some old devices or locked-down browsers lack WebGL, so a fallback is needed.

What we pick: WebGL for the base map and the heavy layers (traffic, routes, many markers). DOM for the few things that need real HTML: the info window of a selected place, the controls, and the side panel. WebGPU has started shipping in the major browsers, but WebGL 2 runs on far more devices and drivers today, so it is the safe default.

The tile pipeline: network, worker, GPU

Decoding a dense vector tile and turning its polygons into triangles can take tens of milliseconds on a phone. If that ran on the main thread, every new tile would drop one or more frames. So the work is split:

  1. The main thread decides which tiles are needed (the math from the last chapter) and asks a pool of two to four Web Workers for them.
  2. A worker calls fetch for the tile, decodes the Protocol Buffers message, applies the style filters, splits polygons into triangles, lays out label glyphs, and writes everything into typed arrays such as Float32Array.
  3. The worker posts the arrays back to the main thread as transferable objects, which moves the memory without copying it.
  4. The main thread uploads the arrays into GPU buffers (fast, around 1 ms) and the tile is ready to draw on the next frame.

Each tile has a life cycle, best modelled as a state machine: requested, loading, parsing in a worker, ready. It can fail and retry with backoff, be cancelled when the user pans away before it arrives (the worker aborts the fetch with an AbortController), and be evicted when memory runs low.

The life cycle of one tile as a state machine. Needed goes to loading when the fetch starts. Loading goes to parsing when bytes arrive, and parsing goes to ready when the worker's buffers are uploaded. Loading can go to failed on an error, and failed goes back to loading on a retry with backoff. Loading or parsing goes to cancelled when the tile leaves the view. Ready goes to evicted when the cache is over budget. Evicted tiles come back quickly from the HTTP cache.neededloadingparsingreadyfetchbytesuploadfailedcancelledevictederrorretry, backoffleft viewover budgetevicted or cancelled: next need starts at needed
Every arrow is an event the cache must handle. The two easy to forget are cancelled (the user panned away, so abort the request) and evicted (free the GPU buffers, but never for a tile still in view).

Layering: one camera, many layers

Every layer draws with the same camera, so a coordinate means the same screen spot in all of them. The figure shows the order from bottom to top. Labels sit above traffic and the route, so road names stay readable on the blue route line, as in Google Maps and most map apps. Search markers sit above labels and take part in label collision, and only DOM overlays such as an info window sit above the canvas.

The layer stack, from top to bottom: DOM info card and controls, over the canvas; then inside one WebGL canvas with one camera: the location dot, search markers, labels and icons, the route line, traffic, roads and buildings, and land and water at the bottom. Traffic refreshes every minute; the base map is cached for days.DOM: info card, controlslocation dotsearch markerslabels and iconsroute linetrafficroads and buildingsland and waterHTML over the canvas: drawn lastone WebGL canvas,one cameratraffic: refreshed every minutebase map: cached for days
Labels sit above the route and traffic so road names stay readable. Markers sit above labels and take part in collision, so a base map label gives way to a search pin. Only the top layer is HTML.

Traffic is a separate tile source, not part of the base map. Base tiles change every few weeks and can be cached for days; traffic tiles change every minute or two and carry a short Cache-Control: max-age of about 60 seconds. Splitting them means a traffic refresh never refetches roads. See the HTTP caching lesson for how max-age, ETag, and stale-while-revalidate work together.

Device pixel ratio

A phone with window.devicePixelRatio of 3 has nine physical pixels per CSS pixel. The canvas must be sized in physical pixels (canvas.width = cssWidth * dpr) or the map looks blurry. But a 400 by 800 CSS pixel map at 3x is about 2.9 million pixels to fill every frame, too many for a cheap phone GPU.

The usual compromise is to cap the ratio at 2 for the map, where text still looks sharp. With vector tiles the density changes only how we draw, not which tiles we fetch; with raster tiles it means fetching @2x tiles.

The tile cache and LRU eviction

Without a cache, panning left then right refetches the same tiles, and zooming out shows blank squares. Without a limit, a user who pans across a continent fills memory until the tab crashes.

So we keep a least recently used (LRU) cache: a map from tile key (z/x/y) to the decoded tile, with a fixed budget. Every frame that draws a tile marks it as used. When the cache is over budget, the tiles unused for longest are evicted and their GPU buffers deleted, but tiles in view are never evicted.

ts
class TileCache {
  private tiles = new Map<string, Tile>(); // Map keeps insertion order
  constructor(private maxTiles = 300) {}

  get(key: string) {
    const tile = this.tiles.get(key);
    if (tile) { this.tiles.delete(key); this.tiles.set(key, tile); } // now most recent
    return tile;
  }

  set(key: string, tile: Tile, inView: Set<string>) {
    this.tiles.delete(key);                         // re-adding moves it to the end
    this.tiles.set(key, tile);
    for (const [k, old] of this.tiles) {          // oldest first
      if (this.tiles.size <= this.maxTiles) break;
      if (inView.has(k)) continue;                 // never evict what is on screen
      old.release();                               // delete GPU buffers
      this.tiles.delete(k);
    }
  }
}
A JavaScript Map iterates in insertion order, so deleting and re-adding a key on every read makes it a simple LRU.

Size the budget by memory, not just count: a decoded tile with its GPU buffers can be 0.5 to 2 MB in a dense city, so 150 to 300 tiles covers the view, its neighbours, and some zoom history. Halve it on phones. Behind this cache sits the browser HTTP cache with the compressed bytes on disk, so an evicted tile usually comes back in a few milliseconds, not a network round trip.

Placeholders: never show a blank square

When a needed tile is not ready, the renderer looks for something it already has that covers the same ground:

  • A parent tile. Tile (15, 16592, 11272) is covered by its parent (14, 8296, 5636), which is covered by (13, 4148, 2818), and so on. Drawing the nearest loaded ancestor, scaled up and clipped to the child's area, gives a blurry but correct picture. Divide x and y by 2 (rounding down) per level up.
  • Children. When zooming out, the four children of a missing tile may already be loaded; drawing them scaled down fills the gap with sharp data.
  • Fade in. When the real tile arrives, fade it in over about 150 ms so the switch from blurry to sharp does not flash.

Pinning the five tiles of zooms 0 and 1 in the cache (the lab below does this) guarantees there is always some ancestor to draw, and real renderers also keep a few low zoom ancestors of the current view. Try it in the lab: pan and zoom, and watch which tiles come from the network, which from the cache, and which are painted from a parent for now.

Network

First load: 0 from cache, 12 new requests.

A 4 by 3 tile viewport at zoom 3. 3/2/2: network. 3/3/2: network. 3/4/2: network. 3/5/2: network. 3/2/3: network. 3/3/3: network. 3/4/3: network. 3/5/3: network. 3/2/4: network. 3/3/4: network. 3/4/4: network. 3/5/4: network. Requests so far 12, cache 12 of 20 tiles, evicted 0, cancelled 0.3/2/2network3/3/2network3/4/2network3/5/2network3/2/3network3/3/3network3/4/3network3/5/3network3/2/4network3/3/4network3/4/4network3/5/4networkzoom 3: requests 12, cache 12 of 20, evicted 0, cancelled 0zoom 0 and 1 stay loaded, so a parent always exists
First load: 0 from cache, 12 new requests. Switch to Slow and pan twice before delivering: tiles that left the view are cancelled, and new squares show a scaled-up parent until Deliver tiles. Pan back and forth to see cache hits, and keep going to see the LRU evict.

Check your understanding · Exercise 4

A user flings the map quickly across a city at zoom 16 on a slow network. Which behaviours does this design give? Select all that apply.

Panning and zooming that stick to the finger

The map's state while you move it is tiny: a camera with a center, a zoom, a bearing (rotation), and a pitch (tilt). Every gesture is a function that changes the camera; the renderer reads the camera once per frame and draws. Keeping this split is what makes input feel direct. The figma and excalidraw guides build the same camera for an infinite canvas; on a map, zoom is tied to tile levels, the world wraps around, and the camera can rotate and tilt.

ts
type Camera = {
  cx: number; cy: number;  // center in the unit square (0 to 1), from project()
  zoom: number;            // fractional, for example 14.6
  bearing: number;         // degrees clockwise from north
  pitch: number;           // degrees of tilt, 0 is straight down
};
Storing the center in projected units, not degrees, makes every pan and zoom a simple addition.

Input changes the camera, frames draw it

The naive version redraws inside every pointermove handler. Browsers often merge fast mouse input into about one pointermove per frame, but not always, and a slow draw inside the handler delays the next event, so the map lags behind the hand. Instead, handlers only update the camera and request a frame; one requestAnimationFrame callback draws the latest camera. The runtime performance lesson covers this pattern.

  • Use Pointer Events (pointerdown, pointermove, pointerup, pointercancel) so mouse, pen, and touch share one code path. Call setPointerCapture on down so the drag keeps working when the pointer leaves the map or crosses an overlay.
  • Set touch-action: none on the map element so the browser does not scroll or zoom the page itself during a touch drag or pinch.
  • A drag moves the center by the pointer delta divided by the world size at the current zoom: cx -= dx / (256 * 2 ** zoom). If the map is rotated, rotate the delta by the bearing first.
  • Wrap cx into 0 to 1 so the user can pan east forever, and clamp cy so the poles never leave the top and bottom of the screen.

Inertia: the fling

If the map stops dead when the finger lifts, it feels like dragging paper on glue. The recipe: during the drag, keep the pointer positions and timestamps from the last 100 ms or so. On pointerup, compute the velocity from them (pixels per millisecond).

If the velocity is above a small threshold, start an animation that moves the camera by it each frame and decays it exponentially, for example v = v0 * Math.exp(-t / 300) with t in milliseconds. Stop when the speed drops below about a pixel per frame, or as soon as a new pointerdown arrives. Using only the recent samples matters: if the user drags, pauses, and lifts, the velocity is zero and the map should not jump.

Zoom around the cursor

When the user scrolls the wheel with the mouse over Lyon, Lyon must stay under the mouse while the rest of the map grows around it. Zooming around the center instead throws the place they were looking at off screen. The math: find the world point under the cursor before the zoom, then choose the new center so that the same point is under the cursor after it.

ts
const worldSize = (z: number) => 256 * 2 ** z; // Google's zoom numbering

// sx, sy: cursor in CSS pixels; w, h: map size; dz: zoom change, like +0.25
function zoomAt(cam: Camera, sx: number, sy: number, w: number, h: number, dz: number): Camera {
  const zoom = Math.min(22, Math.max(2, cam.zoom + dz));
  const dx = sx - w / 2, dy = sy - h / 2;         // cursor offset from the center
  const px = cam.cx + dx / worldSize(cam.zoom);   // the point under the cursor...
  const py = cam.cy + dy / worldSize(cam.zoom);
  return {                                         // ...stays under the cursor
    ...cam, zoom,
    cx: px - dx / worldSize(zoom),
    cy: py - dy / worldSize(zoom),
  };
}
Rotate (dx, dy) by the bearing first if the map is rotated. Pinch uses the same function, anchored at the midpoint of the two fingers.
Zooming around the cursor, before and after. Before, at zoom 14, the cursor is over point P, up and to the right of the screen center C. After zooming to 15, P is still exactly under the cursor. The new screen center C prime is halfway between P and the old center C, which has moved down and to the left, twice as far from P as before.before: zoom 14CPafter: zoom 15CC'Pcursor over P at zoom 14P stays under the cursor
Find the world point under the cursor, zoom, then set the center so that point lands back under the cursor. One zoom level doubles every distance from P, so the old center ends up twice as far away. Pinch uses the same rule with the midpoint between the fingers as P.

Wheel, trackpad, and pinch

  • Mouse wheel: on a full-page map like google.com/maps a plain wheel zooms, unlike a design tool where it pans. Each notch should be a fixed, animated step, for example half or one zoom level. Check deltaMode: some browsers report lines, not pixels.
  • Trackpad pinch: Chromium and Firefox report it as wheel events with ctrlKey set to true and small deltaY values; map them to a continuous zoom such as dz = -deltaY * 0.01. Safari on macOS instead fires its own non-standard gesturestart and gesturechange events with a scale, so handle both. Register the listener with { passive: false } and call preventDefault(), or the browser zooms the whole page.
  • Trackpad two-finger scroll: arrives as plain wheel with small, frequent deltas. Many maps treat it as zoom on a full-page map, and as page scroll on an embedded one.
  • Touch pinch: track both pointers. New zoom is the start zoom plus Math.log2(distance / startDistance), anchored at the midpoint; a change in the angle between the fingers changes the bearing; two fingers moving up or down together change the pitch.
  • Double click or double tap: zoom in one level around that point, animated. In many map apps a two-finger tap zooms out.

Fractional zoom and smooth animation

Because zoom is a real number, a wheel notch sets a target zoom and an animation moves the camera there over 200 to 300 ms with an ease-out curve. Zoom is already a logarithm of scale, so interpolating it linearly gives a steady visual speed. Meanwhile the renderer draws zoom 14 tiles scaled up, and switches to zoom 15 tiles once they are loaded.

Long jumps, such as flying from Paris to Tokyo after a search, zoom out, pan, and zoom back in along one smooth path rather than panning across 10,000 km at street level. The classic method is van Wijk and Nuij's 2003 paper on smooth and efficient zooming and panning, which MapLibre's flyTo follows.

The sequence below shows one drag and fling end to end: what each event does, when frames draw, when tiles load, and when the URL changes.

Sequence of a drag and fling across five actors: user, engine, workers, CDN, and history. The user presses the pointer and the engine captures it. Eight pointermove events update the camera; each animation frame draws once. The engine asks the workers for 6 new tiles; the workers fetch them from the CDN, which returns the bytes; the workers send back GPU-ready buffers. The user lifts the finger with speed, and the engine runs a fling. When the camera settles, the engine calls replaceState with the new view.UserEngineWorkersCDNHistorypointerdownpointermove x8need 6 tilesGET z/x/ytile bytesbufferslift: flingreplaceState()
Input never draws: it only moves the camera, and one requestAnimationFrame callback draws per frame. Tile work happens in workers, and the URL changes once at the end with replaceState, not on every move.

Check your understanding · Exercise 5

Users report that zooming with the trackpad on a Mac in Chrome zooms the whole web page instead of the map. Which fixes are right? Select all that apply.

Markers at scale: drawing, clustering, and hit testing

Search returns 20 places. That is easy. But the same map must also show "all EV chargers" (thousands in a big city), a user's 3,000 saved places, or every photo spot in a country. The question is how a marker is drawn, how thousands become a readable picture, and how a click finds the marker under the pointer.

DOM markers or markers in the map canvas

The naive approach is one absolutely positioned div per marker, moved with a CSS transform every frame. With 20 markers it is perfect: real HTML, hover states, focus, and screen reader names for free. With 5,000 it falls apart: every frame writes 5,000 style changes and the browser recalculates and composites them all. By rough estimate that is 20 to 40 ms on a mid-range laptop, so panning drops well below 60 frames per second.

Decision How should markers be drawn?

One DOM element per marker

A button per marker, placed with transform: translate(...) from the camera every frame.

Pros

  • Rich content (photos, prices, badges) with plain HTML and CSS.
  • Keyboard focus, aria-label, and click handling come from the browser.
  • Easy to animate one marker, such as the selected one.

Cons

  • Cost grows with the count on every frame: fine up to roughly a few hundred, painful in the thousands.
  • DOM markers cannot join the map's label collision, so they cover road names and shop labels.

Drawn into the map canvas (WebGL symbols)

Marker icons go into a texture atlas. Each marker is two triangles in a GPU buffer; the camera moves them all in one draw call.

Pros

  • Tens of thousands of markers at 60 frames per second: the cost per frame is nearly flat.
  • Markers join label collision: base map labels give way to a search pin, and pins of lower rank hide behind higher ones.

Cons

  • No free accessibility or click handling: we must build hit testing and a separate accessible list.
  • Rich content per marker needs pre-rendered images.

Hybrid

Draw all markers in the canvas. Render only the selected or hovered marker, and its info window, as DOM on top.

Pros

  • Scale of the canvas, plus rich HTML exactly where the user is looking.
  • The side panel list carries the accessible names for all results.

Cons

  • Two code paths that must agree on position, to the pixel, while the map moves.

What we pick: Hybrid. Search results, layers, and saved places are canvas symbols that take part in label collision. The selected place gets a DOM info card positioned from the camera each frame, which is one element, not thousands. The side panel gives every result a real, focusable list entry.

Clustering: from 10,000 dots to a readable map

Even drawn cheaply, 10,000 overlapping pins at zoom 10 are a smear. Clustering merges nearby points into one circle labelled with a count, and splits them as you zoom in. There are two common ways.

Decision How should points be grouped?

Grid clustering

Divide the screen (or the world at this zoom) into cells of, say, 60 by 60 pixels. All points in one cell become one cluster, drawn at their average position.

Pros

  • A few lines of code, linear time, easy to reason about.

Cons

  • Two points 2 pixels apart on either side of a cell edge stay separate, while points at opposite corners of one cell merge.
  • Clusters jump when the grid moves, so cells must be aligned to world coordinates, not the screen.
  • Recomputed at every zoom from scratch.

Hierarchical clustering (supercluster)

Once, up front, build a spatial index (a KD-tree) of the points. For each zoom from the deepest (16 by default) to 0, greedily merge points within a radius (40 pixels by default) of the zoom below into clusters, and index those.

At runtime ask for the clusters in the view at the current integer zoom.

Pros

  • Queries take well under a millisecond: the work was done once.
  • Clusters nest: a cluster at zoom 10 is made of clusters at zoom 11, so zooming in splits them predictably.
  • Can answer "at what zoom does this cluster split?" and "list the points in it".

Cons

  • An up-front build that grows with the data: a few milliseconds for thousands of points, about a second for 400,000 in the author's benchmark, so run it in a Web Worker.
  • Rebuild needed when the point set changes.

Server-side clustering

The server returns clusters per tile, like any other vector tile layer.

Pros

  • Works for millions of points, such as all photos on Earth, without sending them to the client.

Cons

  • Every zoom change is a network round trip; filters on the client need a new request.

What we pick: Hierarchical clustering in a Web Worker for any layer the client can hold (up to around a few hundred thousand points). Server-side cluster tiles for planet-sized layers. Grid clustering is a fine first sketch in the interview, as long as you name its edge artifacts.

ts
// cluster.worker.ts
import Supercluster from "supercluster";

let index: Supercluster;
self.onmessage = (e: MessageEvent) => {
  const msg = e.data;
  if (msg.type === "load") {
    index = new Supercluster({ radius: 60, maxZoom: 16 });
    index.load(msg.points); // GeoJSON Point features
  } else if (msg.type === "view") {
    const { bbox, zoom, requestId } = msg;  // [west, south, east, north]
    const clusters = index.getClusters(bbox, Math.floor(zoom));
    self.postMessage({ requestId, clusters });
  }
};
The main thread sends the view on every camera change (at most once per frame) and drops replies whose requestId is older than the latest.

Clicking a cluster should zoom to where it splits: supercluster's getClusterExpansionZoom(clusterId) gives that zoom, and we fly there centred on the cluster. Try it in the lab below: change the zoom and watch clusters split, then compare the cost of keeping every point as a DOM node with drawing clusters in the canvas.

Zoom
Places
Draw as

Zoom 12, 2.0k places, clustering on, DOM markers: 12 markers drawn for 1,630 places in view, about 0.1 ms per frame, which fits the frame budget.

A map area at zoom 12 showing 12 clusters and single places. Zoom 12, 2.0k places, clustering on, DOM markers: 12 markers drawn for 1,630 places in view, about 0.1 ms per frame, which fits the frame budget.17052016034012070901003012 markers drawn, about 0.1 ms per frame
Zoom 12, 2.0k places, clustering on, DOM markers: 12 markers drawn for 1,630 places in view, about 0.1 ms per frame, which fits the frame budget. Each dot stands for 10 places. The cost is a rough estimate, not a measurement: about 5 microseconds per DOM marker per frame, and a flat 0.5 ms plus a tiny amount per canvas symbol.

Hit testing canvas markers

A canvas has no click per marker, so we find what is under the pointer ourselves. Two approaches work:

  • A spatial index in screen space. After each frame's placement, insert every visible marker's box into a grid (or an R-tree such as rbush). On a click or hover, query the cell under the pointer and test the few candidates from the top-most down. A few hundred visible markers makes this microseconds. This is the same idea as hit testing shapes in the figma and excalidraw guides, with the difference that markers keep a fixed size in pixels while the map scales.
  • GPU picking. Draw every marker into a hidden framebuffer with a unique color that encodes its id, then read the one pixel under the pointer with readPixels. Exact even for odd shapes, but reading from the GPU can stall the pipeline, so do it only on click, not on every move.

Make hit areas at least 24 by 24 CSS pixels (the WCAG 2.2 minimum target size), larger on touch, and run hover tests at most once per frame.

Check your understanding · Exercise 6

A layer shows 8,000 EV chargers across a country. Which choices fit this design? Select all that apply.

Directions: routes, polylines, and live traffic

Directions combine everything so far: autocompletes for the endpoints, a routing request, several routes drawn and listed, and traffic that changes while the user looks. The algorithm lives on the server; the front end owns the request, the transfer format, the drawing, and the text.

The route request

A request names an origin, a destination, optional stops in between, a travel mode, options such as "avoid tolls" and a departure time, and asks for alternatives. The response has two or three routes, each with a duration in current traffic, a distance, a summary ("via A6"), a geometry, a list of steps, and traffic speed by segment.

Google's public Routes API works this way: it can return alternative routes and, on request, speed readings for stretches of the route, given as ranges of polyline point indexes and each marked normal, slow, or traffic jam.

  • Fire the request as soon as both ends are set, and again when the mode or options change. Changing the mode is common and fast, so cache each mode's answer for the same endpoints for a minute or two: switching back to driving should be instant.
  • Abort the previous request when a new one starts, exactly as for search.
  • Show the route list as soon as the response arrives, then fit the camera to the route's bounds with padding for the panel.

Shipping a route: the encoded polyline

A 500 km drive has thousands of points. As JSON with full floating point numbers, [48.858412,2.294503] costs about 20 bytes a point. Google's encoded polyline format squeezes that to a few bytes per point with three ideas, and the same format is used by many other routing services:

  1. Round to 5 decimal places and store integers: 38.5 becomes 3,850,000. Five decimals is about 1.1 metres at the equator, plenty for drawing.
  2. Store differences. After the first point, each point is stored as the change from the previous one. Neighbouring points on a road are close, so the numbers are small.
  3. Variable-length text encoding. Shift the number left one bit and invert it if negative (so the sign lives in the lowest bit), cut it into 5-bit chunks from the low end, set the 0x20 bit on every chunk except the last to say "more follows", and add 63 to each to land on printable ASCII characters.

Worked example, from the format's documentation: the points (38.5, -120.2), (40.7, -120.95), (43.252, -126.453). The first latitude 3,850,000 encodes to _p~iF and the first longitude -12,020,000 to ~ps|U. The second point is stored as the change (+220,000, -75,000), which encodes to _ulL and nnqC, already shorter. A typical road point costs 2 to 4 characters per coordinate, and the string is safe in JSON and URLs.

Encoding latitude 38.5 as an encoded polyline. Multiply by 100,000 and round to get 3,850,000. Shift left one bit to get 7,700,000, inverting if negative. Cut into five chunks of 5 bits, low chunk first, setting 0x20 on all but the last. Add 63 to each to get printable characters. The result is the 5 characters underscore, p, tilde, i, F. Later points store only the difference from the previous point, usually 2 to 4 characters each.38.5latitude3,850,000x 100,000, rounded7,700,000shift left 1 bit5 chunks5 bits, 0x20 = more+63 eachprintable ASCII_p~iF5 characterslater points store differences: 2 to 4 characters each
Rounding, a sign trick, and 5-bit chunks turn a 20-byte JSON pair into a few characters. The first point is the expensive one; after that, small differences make short strings like _ulL.
ts
function decodePolyline(str: string, precision = 5): [number, number][] {
  const factor = 10 ** precision;
  const points: [number, number][] = [];
  let i = 0, lat = 0, lng = 0;
  const next = () => {
    let result = 0, shift = 0, b: number;
    do {
      b = str.charCodeAt(i++) - 63;          // back to a 6-bit value
      result |= (b & 0x1f) << shift;         // low 5 bits are data
      shift += 5;
    } while (b >= 0x20);                     // 0x20 means "more chunks follow"
    return result & 1 ? ~(result >> 1) : result >> 1; // undo the sign trick
  };
  while (i < str.length) {
    lat += next();
    lng += next();
    points.push([lat / factor, lng / factor]);
  }
  return points;
}
// decodePolyline("_p~iF~ps|U_ulLnnqC_mqNvxq`@")
// gives [[38.5, -120.2], [40.7, -120.95], [43.252, -126.453]]
Decoding runs in the worker for long routes. The bitwise operators work on 32-bit integers, which is enough for 5-decimal coordinates.

Drawing the route at every zoom

Drawing 8,000 points at zoom 5, where the whole route is 300 pixels long, wastes work. So we simplify per zoom with the Douglas-Peucker algorithm: keep the two ends, find the point farthest from the line between them, keep it if it is farther than a tolerance, and repeat on both halves. With a tolerance of about half a pixel, the route keeps a few hundred points at zoom 6 and looks identical. Compute a few zoom bands once per route, in the worker.

  • Line with a casing. The route is drawn twice: a wider dark outline, then the blue line on top, so it stands out on any background. Widths are in screen pixels, so the line stays the same thickness at every zoom.
  • Alternatives are drawn first in grey, under the chosen route, each with a small label showing its time. Clicking a grey route selects it, which uses the same hit testing as markers but against line segments (distance from point to segment, under about 8 pixels).
  • Road names stay readable: the route layer sits below the label layer, as shown in the rendering chapter.

Live traffic as colored segments

The response marks stretches of the chosen route by point index: points 0 to 140 normal, 141 to 188 slow, 189 to 230 jammed. The renderer splits the line into runs and colors them, for example blue or green for normal, orange for slow, and red for a jam. Because the speed data refers to point indexes in the full polyline, apply it before simplifying, or keep a map from simplified points back to the original indexes.

Traffic changes, so while the directions panel is open, refresh the routes every few minutes in the background and update the times in place. If a much faster route appears, offer it instead of switching without asking.

The step by step list

Each step has an instruction ("Turn left onto Rue de Rivoli"), a maneuver type for the icon, a distance, and the range of points it covers. The list is real text in the side panel, an ordered list with headings, which is also what makes directions usable without seeing the map. Hovering or focusing a step highlights its stretch of the route; clicking it moves the camera to that maneuver. Distances are formatted for the user's locale and unit system, covered in the internationalization chapter.

The sequence shows a directions request from picking the destination to drawing the route, and what happens when the network fails or no route exists.

Sequence for directions across user, panel, routes API, worker, and engine. The user picks a destination. The panel posts the directions request to the routes API, which returns three routes with encoded polylines. The panel sends them to a worker to decode and simplify; the worker gives the engine a route layer with buffers. The panel shows the user the list of routes and steps. Failure paths: the API can answer NO_ROUTE, and the panel suggests trying transit; if the network fails, the panel keeps the last route and offers a retry.UserPanelRoutes APIWorkerEnginedestinationPOST request3 routesdecode linesroute layerroutes + stepsNO_ROUTEoffline: retry
The list appears as soon as the JSON arrives; the line on the map follows a few milliseconds later from the worker. Neither failure leaves the user with a blank panel: there is always a next step to offer.

Check your understanding · Exercise 8

Which statements about the encoded polyline and route drawing are true? Select all that apply.

The URL as the source of truth

People share maps constantly: "meet here", "this is the route", "look at this place". A link that opens the world map instead of the view they meant is a broken product. So the URL is not a side effect of the app's state; for everything shareable, it is the state. The URL as state lesson covers the general idea; here is how it applies to a map.

What goes in the URL

Google Maps URLs have a recognisable shape. A plain view is /maps/@48.8583701,2.2944813,17z: latitude, longitude, and zoom after an @. A place adds a path segment, /maps/place/Eiffel+Tower/@48.85837,2.29448,17z/data=..., where an undocumented data= part carries ids and options, and directions use /maps/dir/Paris/Lyon/@.... Satellite and 3D views use other forms after the @, such as a distance in metres instead of a zoom.

That format is internal and can change. For links built by other sites, Google documents a separate, stable Maps URLs API, such as https://www.google.com/maps/search/?api=1&query=Eiffel+Tower. We copy the idea of a readable path, with a format of our own:

StateIn the URL?ExampleWhy
Center and zoomYes@48.8584,2.2945,15zThe minimum for a shared view to make sense.
Bearing and tiltYes, only when not zero,30h,45tA rotated 3D view should reopen rotated, but the common case stays short.
Open placeYes/place/eiffel-tower/p_8f3k2qA stable id makes the link work; the readable name is for humans and is ignored when parsing.
Search query and searched areaYes/search/bakery/@...A shared search must show the same results, so the area is part of it.
DirectionsYes/dir/{origin}/{destination}?mode=transit&alt=1The route and the chosen alternative are what the person wants to share.
Layers (traffic, satellite)Yes?layer=trafficChanges what the map means.
Hovered result, open menus, panel scrollNoKept in component stateMoment-to-moment UI state: noise in history and links.
Session token, request idsNoKept in memory onlyPrivate, short-lived, and meaningless to another person.

Round coordinates to what the zoom needs. Five decimal places is about 1 metre, enough at street zoom; Google writes seven. Fewer digits mean shorter links and a stable URL when the camera moves by a fraction of a pixel.

History: push for meaning, replace for motion

The naive design calls history.pushState every time the camera changes. A ten second pan creates hundreds of entries, and the Back button becomes useless: it scrolls the map back a few pixels at a time. The opposite mistake never pushes, and Back leaves the app entirely when the user only wanted to close a place. The rule:

  • Replace while the camera moves: once it settles (about 300 ms after the last change) call history.replaceState with the new @lat,lng,zoom. Throttle it: browsers limit how often history can be changed, and Safari throws a SecurityError after 100 calls in 30 seconds.
  • Push when the meaning changes: opening a place, running a new search, starting directions, switching to satellite. Back then closes the place or returns to the results, which is what users expect.
  • On popstate, parse the URL into state and apply it: open or close panels, and animate the camera to the stored view rather than jumping, so the user sees where they went back to.
URL state for the map. The URL has three parts: a place id, where opening a place pushes a history entry; the view @48.8584,2.2945,15z, where panning and zooming replace the current entry; and a layer parameter, where switching layers pushes. After a session the history stack holds four entries: search, place, route, and satellite, the current one. Panning inside an entry only replaces it. Back from route goes to place and the camera animates back. Hover, scroll, and the session token are never in the URL./place/p_8f3k2qopen place: push@48.8584,2.2945,15zpan, zoom: replace?layer=trafficlayer: pushhistory stack after a sessionsearchplaceroutesatellitepans inside an entry only replace itBack: route to place,camera animates backnever in the URL: hover,scroll, session token
Meaning changes push, motion replaces. Four entries for a whole session is right; four hundred, one per pointermove, makes Back useless.
ts
const VIEW = /@(-?\d+\.?\d*),(-?\d+\.?\d*),(\d+\.?\d*)z/;

function parseView(path: string): View | null {
  const m = VIEW.exec(path);
  if (!m) return null;
  const [lat, lng, zoom] = m.slice(1).map(Number);
  if (Math.abs(lat) > 85.05 || Math.abs(lng) > 180 || zoom < 0 || zoom > 22) return null;
  return { lat, lng, zoom };
}

function formatView({ lat, lng, zoom }: View) {
  const d = zoom >= 12 ? 5 : 3;                       // fewer digits when zoomed out
  return `@${lat.toFixed(d)},${lng.toFixed(d)},${+zoom.toFixed(2)}z`;
}

let settle: number | undefined;
camera.subscribe((view) => {
  clearTimeout(settle);
  settle = window.setTimeout(() => {
    history.replaceState(history.state, "", withView(location.pathname, formatView(view)));
  }, 300);
});
Validate everything parsed from the URL: links are user input. An invalid view falls back to the user's area or a default.

Booting from the URL is the other half. On load the app parses the path first, before anything else, and starts fetching tiles for that view and the place or route it names in parallel. If the URL has no view, the app uses a coarse location from the server (based on IP, city level) and only asks for precise geolocation when the user presses "my location".

Prompting for location on page load, before the user has asked for anything, annoys people and is often denied. Asking when they press the button explains itself, so people are far more likely to agree.

Check your understanding · Exercise 9

A user opens a place, pans around for 20 seconds, then presses Back. What should happen? Select all that apply.

The place details panel

Clicking a marker or a result opens the place: name, category, rating, hours, address, phone, website, a row of actions, a photo strip, popular times, and reviews. A popular place has thousands of photos and reviews. The panel must open instantly, and must not download all of that to show the first screen.

Open instantly, fill in progressively

The naive panel shows a spinner, fetches one big "place" object with everything, and renders when it lands: 400 to 800 ms of nothing on a phone, and hundreds of kilobytes for reviews the user may never scroll to. Instead, split the panel by what the user sees first, in three tiers:

  1. Instant, from what we have. The search result or the map label already had the id, name, category, rating, position, and a thumbnail. Render the header from that at once, from the store, and mark the rest as loading with skeletons of the right height so nothing jumps.
  2. Core details, one request. Hours, address, phone, website, the first 6 to 10 photo thumbnails, and a review summary (average, count, and the distribution of stars). One small JSON response, typically a few kilobytes. Prefetch it when the user hovers a result for more than about 100 ms or focuses it with the keyboard, so a click often finds it in cache.
  3. On demand. More photos when the user scrolls the strip, reviews when the reviews section approaches the viewport (an IntersectionObserver with a rootMargin of a few hundred pixels), popular times when it is opened, and every "more" after that via cursor pagination.

The panel's code is its own chunk too. The amazon and airbnb guides use the same split for product and listing pages; what is specific here is that the header data almost always exists before the click.

Photos

  • Ask the image server for the size you will display: thumbnails at about 200 by 150 CSS pixels, with a srcset for 1x and 2x density, in AVIF or WebP. The image optimization lesson covers formats, srcset, and sizes in depth.
  • Lazy load with loading="lazy" on everything past the first two thumbnails, and give each image fixed width and height so the strip does not shift as images arrive.
  • The full-screen photo viewer is a separate lazy chunk, opened as a dialog with focus moved into it and returned on close.

Reviews

Reviews are a paginated list with sorting (most relevant, newest, highest, lowest). Fetch 10 at a time with a cursor, and key the cache by place id and sort so switching back is instant. Clamp long reviews with a "More" button, and label machine-translated ones with a toggle to see the original.

Opening hours live in the place's time zone

A subtle bug: a user in London looks at a restaurant in New York at 15:00 London time. If the client computes "Open now" from the user's clock, it says open, but it is 10:00 in New York and the restaurant opens at noon.

The fix: the server sends hours in the place's local time plus its IANA time zone (such as America/New_York), and the client computes "open now" and "closes soon" in that zone with Intl.DateTimeFormat and its timeZone option, or simply shows the status the server computed. The locale formatting lesson covers the Intl APIs.

Think first

The user opens a place, closes it, pans, and opens it again a minute later. Should the client refetch the details?

Check your understanding · Exercise 10

Which choices make the place panel feel instant without wasting data? Select all that apply.

Architecture, data model, and interfaces

With the hard parts designed, we can draw the whole app. The key boundary is between the map engine, which runs a frame loop and owns the GPU, and the app shell, which is ordinary UI (search box, panels, buttons) built with a component framework such as React. They talk through a small, explicit interface, not shared component state.

PartRuns whereResponsibility
App shellMain thread, ReactSearch box, side panel (results, place, directions), controls, dialogs. Reads the store, never touches the GPU.
Map engineMain thread, plain TypeScript classOwns the camera, gesture handlers, the frame loop, layers, label placement, hit testing. Its interface (setCamera, flyTo, setLayer, on) is at the end of this chapter.
Tile workers (2 to 4)Web WorkersFetch, decode, style-filter, and triangulate tiles; cluster points; decode and simplify routes.
Tile cacheMain thread memory and GPULRU of decoded tiles with their GPU buffers; placeholder lookup by ancestor and children.
StoreMain threadServer data (places, searches, routes) keyed by request, plus UI state (selected place, hovered id, panel mode).
URL syncMain threadParses the URL into the store on load and on popstate; writes the store back with push or replace.
API clientMain threadTyped requests for search, autocomplete, places, directions; abort, retry with backoff, and error mapping.
Service workerSeparate threadCaches the app shell, fonts, glyphs, and sprites; a small runtime cache of recently viewed tiles where the terms allow.
Architecture. On the main thread: the app shell in React with panels, search, and controls; the map engine with camera, gestures, and layers; the store with places, searches, and routes; the tile cache, an LRU of decoded tiles and GPU buffers; the URL sync that pushes or replaces history; and the API client that aborts and retries. The shell reads and writes the store and sends layers and flyTo calls to the engine. The engine reads from the tile cache. The store feeds the URL sync. Off the main thread, tile workers decode tiles and cluster points; they fetch from the tile CDN and fill the tile cache. The API client calls the backend for frontend.Main threadApp shellReact panels, searchMap enginecamera, gestures, layersStoreplaces, searchesTile cacheLRU, GPU buffersURL syncpush or replaceAPI clientabort, retryread, dispatchtilesstateresults go into the storeOff main threadTile workersdecode, clusterNetworkTile CDN.mvt, versionedBFF / APIssearch, placesfetch
The engine and the shell meet at one small interface: the shell sends layers and camera moves, the engine sends clicks and idle events. The only network paths are tiles through workers and JSON through the API client.

Why the boundaries are where they are

  • The camera lives in the engine, not in React state. It changes 60 times a second during a pan, and as component state it would re-render the panel tree every frame. The shell subscribes to camera changes through a throttled callback (for "Search this area" and the URL) and otherwise ignores them.
  • The engine knows nothing about places or routes. It accepts layers of generic data (points with icons and priorities, lines with colors). That keeps it reusable and testable, and keeps product logic in the shell.
  • Workers do anything that takes more than a few milliseconds and does not need the DOM: tile decoding, clustering, polyline decoding and simplification. The main thread stays free for input.
  • The URL sync is the only code that touches history. Features ask for "open place X" through the store; the sync decides push or replace. One owner means one history policy.

Data flow for the main screen

Follow a cold load of a shared link to a place. The URL is parsed first. Then, in parallel, the engine computes the visible tiles and the workers request them from the tile CDN, while the API client requests the place details from the backend for frontend (BFF), a thin server layer built for this client.

Tile bytes come back with long cache lifetimes, are decoded in workers, and land in the tile cache and on the GPU. The place response goes into the store; the panel renders it and the engine gets a marker layer with one selected point. When the user acts (search, select, directions), the flow runs again from the store, and the URL sync writes the result back.

Data flow for opening a shared place link. Top lane: the URL gives the view to the engine, which requests z/x/y tiles from the tile CDN; the bytes go to a worker that decodes and triangulates. The worker passes buffers to the tile cache, which feeds the canvas frame. Bottom lane: the URL's place id goes to the API client, which gets the place from the BFF; the JSON goes into the store, which renders the panel and gives the canvas a marker layer. On a click, the panel writes to the store and the URL sync pushes a history entry.URL@lat,lng,zEnginetile keysTile CDN.mvt, 1 weekWorkerdecodeviewGET z/x/ybytesCanvasone frameTile cacheLRUbuffersplace idAPI clientBFFStoreplaces by idPanelReactGET placeJSONrendermarker layeron a click: Panel writes the Store, URL sync pushes an entry
Two lanes start from the same URL at the same moment: binary tiles through the CDN and workers, JSON through the BFF and the store. They meet only in the canvas, where the place's marker sits on the tiles.

Check your understanding · Exercise 11

Why keep the camera inside the map engine instead of in the React store? Select all that apply.

Data model: what the client keeps, and where

The parts above hold very different kinds of data: binary tiles measured in megabytes, a camera that changes every frame, server entities like places and routes, and UI state like "which result is hovered". Each kind gets its own home, chosen by how often it changes, who else needs it, and whether it must survive a reload or be shared.

ts
type TileKey = `${number}/${number}/${number}`;     // "z/x/y"

type PlaceSummary = {            // from search results and map labels
  id: string; name: string; category: string;
  lat: number; lng: number; rating?: number; reviewCount?: number; thumbUrl?: string;
};
type PlaceDetails = PlaceSummary & {
  address: string; phone?: string; website?: string;
  timeZone: string;              // IANA, for "open now"
  hours: { day: number; open: string; close: string }[];
  photos: { url: string; width: number; height: number }[];
  reviewSummary: { average: number; count: number; histogram: number[] };
};
type Search = {                  // keyed by query + rounded bounds
  query: string; bounds: Bounds; status: "loading" | "ready" | "error";
  placeIds: string[]; nextCursor?: string;
};
type Route = {
  id: string; durationSec: number; durationInTrafficSec: number; distanceM: number;
  summary: string; polyline: string;                  // encoded
  traffic: { from: number; to: number; speed: "normal" | "slow" | "jam" }[];
  steps: { instruction: string; maneuver: string; distanceM: number; from: number; to: number }[];
};
Places are normalized by id: a search stores ids, and the panel and markers read the same PlaceSummary or PlaceDetails record.
DataLives inLifetimeWhy there
CameraMap engine (memory)Session; settled value mirrored to the URLChanges every frame; only the engine needs it at that rate.
Decoded tiles and GPU buffersTile cache (LRU)Until evictedLarge and binary; bounded by a memory budget.
Raw tile bytesBrowser HTTP cache (disk)Days for base tiles, about a minute for trafficThe browser already does this well with Cache-Control.
PlacesStore, normalized by idSession, last N entriesShared by markers, list, and panel.
Searches and routesStore, keyed by requestMinutesInstant back and forth between modes and results.
Selected place, query, route endpoints, layersURL, mirrored into the storeAs long as the link existsShareable and restored by Back.
Hovered id, panel scroll, sheet heightComponent or UI storeMomentsNot worth sharing or persisting.
Recent searches, saved places, home and workServer account, cached in IndexedDBLong-termShown before the network answers; signed-in users see them on every device.
The client data model by home. The URL holds the view, the place id, and the route ends. The store holds places by id, searches as ids plus bounds, routes with polyline and steps, and UI state such as the hovered id and panel mode. The URL's place id points at a place in the store. The map engine holds the camera, which changes every frame, and the tile cache, an LRU with GPU buffers. On disk, the HTTP cache holds tile bytes, and IndexedDB holds recent searches and saved places. Evicted tiles are refetched from the HTTP cache.URLviewplace idroute endsStorePlaceby idSearchids + boundsRoutepolyline, stepsUI statehovered, panelMap engineCameraevery frameTile cacheLRU, GPU buffersDiskHTTP cachetile bytesrefetchIndexedDB: recent, saved
The faster something changes, the closer it lives to its only reader: the camera stays in the engine. Shared things live once in the store, keyed by id. Only shareable things go in the URL.

Interfaces: two kinds of traffic, and the engine API

Two kinds of traffic leave the client, and they want different designs. Static map assets (tiles, styles, fonts, icons) are huge in count, identical for everyone, and cacheable for a long time: plain GET URLs on a CDN. Product APIs (autocomplete, search, places, directions) are personal, change often, and need auth and rate limits: JSON over the BFF. Mixing them, such as putting tiles behind an authenticated API, would ruin CDN hit rates.

Tiles and map assets

http
GET /tiles/v42/base/14/8296/5636.mvt
Accept-Encoding: br, gzip

200 OK
Content-Type: application/vnd.mapbox-vector-tile
Content-Encoding: br
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
ETag: "v42-14-8296-5636"

GET /tiles/traffic/14/8296/5636.mvt?t=2026-10-20T18:20Z
Cache-Control: public, max-age=60
The tile set version (v42) is in the path, so a new map release is a new URL and old tiles can be cached for a week. Traffic uses a time bucket so every client asks for the same URL within a minute.
  • Style: one JSON document (/styles/v42/default.json) listing sources, layers, and paint rules. Dark mode is another style document pointing at the same tiles.
  • Glyphs: fonts as signed distance field bitmaps (images that stay sharp when scaled and rotated) in ranges of 256 characters (/fonts/Roboto Regular/0-255.pbf), fetched only for the scripts that appear in view. A user browsing Tokyo downloads Japanese ranges; a user in Paris never does.
  • Sprites: one atlas image of all icons plus a JSON index of their positions, at 1x and 2x.
  • Serve all of it from a CDN over HTTP/2 or HTTP/3, so 30 parallel tile requests share one connection. The CDN caching lesson covers cache keys and versioned URLs.

Product APIs

http
GET /api/autocomplete?q=eiff&bias=48.85,2.29,15&session=s_9f2&lang=en
200 { "suggestions": [
  { "kind": "place", "placeId": "p_8f3k2q", "main": "Eiffel Tower",
    "secondary": "Av. Gustave Eiffel, Paris", "match": [[0, 4]] } ] }

GET /api/search?q=bakery&bounds=45.74,4.82,45.78,4.87&lang=fr&limit=20
200 { "places": [PlaceSummary, ...], "nextCursor": "c_21", "bounds": [...] }

GET /api/places/p_8f3k2q?fields=core&session=s_9f2&lang=en
200 { "place": PlaceDetails }

GET /api/places/p_8f3k2q/reviews?sort=newest&cursor=r_40&limit=10
The match ranges let the client bold the typed part without guessing. Fields select tiers of the place panel, so the first request stays small.
http
POST /api/directions
{ "origin": { "placeId": "p_paris" }, "destination": { "placeId": "p_lyon" },
  "waypoints": [], "mode": "drive", "departAt": "now",
  "avoid": ["tolls"], "alternatives": true, "units": "metric", "lang": "fr" }

200
{ "routes": [ { "id": "r1", "summary": "A6", "distanceM": 465000,
    "durationSec": 15600, "durationInTrafficSec": 16900,
    "polyline": "...", "traffic": [{ "from": 0, "to": 140, "speed": "normal" }],
    "steps": [ ... ] } ],
  "expiresAt": "2026-10-20T18:25:00Z" }
POST because the request body can be long (many stops, options). The response says how long its traffic data is valid, which drives the background refresh.

Errors use one shape everywhere: { "error": { "code": "NO_ROUTE", "message": "...", "retryable": false } }. The client maps codes to UI: NO_ROUTE and ZERO_RESULTS are normal empty states with suggestions ("try transit", "zoom out"); RATE_LIMITED and 5xx are retryable with backoff; a failed tile is retried quietly and its parent stays on screen.

The map engine's interface

ts
interface MapEngine {
  setCamera(c: Partial<Camera>): void;                 // jump
  flyTo(c: Partial<Camera>, opts?: { durationMs?: number; padding?: Insets }): void;
  fitBounds(b: Bounds, opts?: { padding?: Insets }): void;
  setStyle(url: string): void;
  setLayer(id: string, data: PointLayer | LineLayer | null): void;
  setPadding(p: Insets): void;                          // side panel or bottom sheet
  on(event: "click" | "hover", cb: (hit: Hit | null) => void): () => void;
  on(event: "cameraChange", cb: (c: Camera) => void): () => void;  // every frame
  on(event: "cameraIdle", cb: (c: Camera, bounds: Bounds) => void): () => void;
  destroy(): void;                                       // free GPU memory and workers
}
Padding tells the engine which part of the canvas is covered by the panel, so fitBounds and flyTo center things in the visible part.

Check your understanding · Exercise 12

Which interface choices help the CDN serve tiles well? Select all that apply.

Performance and offline

A map app is heavy by nature: a WebGL engine, a text shaper, a style engine, workers, fonts, and tiles. Users judge it in the first two seconds and again on every drag. So performance has two budgets: the first view, measured by Largest Contentful Paint (LCP), and every frame during interaction, which Interaction to Next Paint (INP) only partly captures because a drag is not a discrete interaction.

The first view

The naive load ships one bundle with the shell, the engine, directions, Street View, and the photo viewer: well over a megabyte of compressed JavaScript. On a mid-range phone that is seconds of download and parse before the first tile request starts, because the tile list depends on the engine's code. The fix: start the expensive things in parallel and show something map-like before the engine is ready.

  1. Server-rendered shell with a static map image. The HTML response already contains the panel (for a place link: the place header) and an img of the map for this exact view, rendered on the server at the right size. The image is the LCP element and paints in the first second. Google's Static Maps API serves this kind of image to developers.
  2. Preload the critical assets from the HTML: the engine chunk, the style JSON, and the handful of low zoom tiles for this view, with link rel=preload or modulepreload hints, so tiles download while the engine parses.
  3. Engine takes over. When the first frame of WebGL tiles is drawn, fade the static image out. If the user drags before then, the image can be moved with a CSS transform as a stand-in, so the first touch is never ignored.
  4. Everything else is a lazy chunk: directions, the photo viewer, reviews, Street View, the layers menu, and "suggest an edit". Each loads on first use, or on idle after the map is ready if it is likely to be needed.
Timeline of the first view, from 0 to 3 seconds. From 0 to about 0.9 seconds the HTML arrives with the panel and a static map image, which is the Largest Contentful Paint. From about 0.4 seconds the engine JavaScript downloads, preloaded, and in parallel the style and tiles download. At about 1.6 seconds the first WebGL frame replaces the image and the map is ready. After that, when the browser is idle, lazy chunks such as directions load.LCP: static mapHTML + map imageengine JSstyle + tilesmap readyfirst WebGL frameidle: lazy chunks0 s1 s2 s3 s
The static image wins LCP while the engine is still downloading, and preload hints start the tiles in parallel instead of after the engine. Directions, Street View, and the photo viewer wait for idle time or first use.

Street View deserves its own note: it is a different renderer (panoramic imagery projected on a sphere), with its own data, and it is often several hundred kilobytes of code. Load it only when the user drags the Pegman or opens a photo sphere, and prefetch it when they hover the Street View control. The code splitting lesson covers route and interaction-based splitting in general.

Keeping 60 frames per second while panning

About 8 to 12 ms of main-thread work leaves room for the browser and the GPU inside 16.7 ms.
WorkWhereBudget per frameHow to keep it there
Input handlingMain threadUnder 1 msHandlers only update the camera; no layout reads.
Tile selection and placeholder lookupMain threadAbout 1 msPure math on a few dozen keys.
GPU uploads of new tilesMain thread2 to 4 msAt most a couple of tiles per frame; queue the rest for the next frames.
Label placementMain thread0 ms on most framesRerun every 300 ms during motion, or when the camera idles.
Draw callsMain thread to GPU2 to 5 msFew draw calls per layer; batch by texture; cap device pixel ratio at 2.
Decoding, triangulating, clusteringWorkersNot on the frameNever on the main thread.
React panelsMain thread0 ms during a panPanels subscribe to camera idle, not camera change.

Measure it: sample frame times during pans from requestAnimationFrame timestamps in a small share of sessions, alert when the 95th percentile goes over 20 ms, and split by device class. Count lost WebGL contexts (webglcontextlost) too: they are the map's version of a crash.

Offline: what the web can and cannot do

Google Maps on Android and iOS lets users download an offline area: tiles, places, and routing data for a region, used when there is no signal. As far as Google's public documentation shows, the web app does not offer offline areas. That is not only a technical choice:

  • Terms of use. Map data is licensed. The Google Maps Platform terms, for example, say customers will not cache Google Maps content except where the service-specific terms allow it, and will not pre-fetch, store, or bulk download tiles; the Map Tiles API policies also rule out offline use. Any web map built on licensed tiles has to check its own terms before caching tiles in a service worker. Self-hosted data such as OpenStreetMap has its own license and usage rules.
  • Storage. A city at zooms 10 to 16 is thousands of vector tiles, tens to hundreds of megabytes. Browsers grant origins storage quotas and can evict it under pressure unless the site asks for persistent storage with navigator.storage.persist(), which browsers may decline.
  • Routing and search would need on-device data and algorithms that the server normally runs.

So the web design aims at surviving short drops, not at full offline use. A service worker caches the app shell, the style, fonts, and sprites, so a reload in a tunnel still opens the app. Tiles already viewed stay in the HTTP cache and the memory LRU, so the area the user was looking at keeps working.

When a request fails, the map shows parent tiles, keeps the last results and route visible, and the panel says "You are offline; showing saved results" with a retry. The service workers lesson covers the caching strategies.

Check your understanding · Exercise 13

Which changes would most improve the first view of a shared place link on a mid-range phone? Select all that apply.

Accessibility and internationalization

A map is the most visual interface on the web, and a WebGL canvas is, to assistive technology, one blank element. The principle that makes it workable: the map must not be the only way to get information. Every result, place, and route the map shows is also in the side panel as real, structured text. Then the map itself only needs to be operable, not narrated pixel by pixel.

Keyboard panning, zooming, and markers

  • The map container is focusable (tabindex="0") with a clear accessible name such as "Map. Use arrow keys to pan, plus and minus to zoom" and a visible focus ring.
  • When focused: arrow keys pan by a fixed fraction of the view (with a short animation), + and - zoom around the center, and Page Up, Page Down, Home, and End pan by larger steps. Google's Maps JavaScript API uses this set of shortcuts, and google.com/maps documents arrow keys, Shift with arrows for bigger steps, and plus and minus. Only handle these keys while the map has focus, so they never steal arrow keys from the page.
  • Markers are reachable without a mouse. The engine keeps a list of visible markers in reading order (by rank, or top to bottom); Tab or a dedicated key moves a virtual focus through them, the engine draws a focus ring, and an aria-live region announces "Boulangerie Paul, 4.5 stars, 2 of 20". Enter opens the place in the panel and moves focus to its heading. Some products instead render an invisible button per visible marker; either way, keep the count small by only including what is in view. Google Maps on the web documents its own version: the focused map highlights a square and lists the places in it by number, and pressing a number opens that place.
  • Map controls are real button elements with names and aria-pressed for toggles such as the traffic layer. The keyboard navigation lesson covers roving focus and shortcut design.

Screen readers and the panel

Screen reader users mostly use the panel: the search combobox, a results list with ratings as text ("4.5 stars, 1,203 reviews"), the place panel with headings, and directions as an ordered list.

A polite live region announces changes the user caused but might not see: "20 results for bakery", "Route found: 4 hours 41 minutes via A6". Google's help pages list the screen reader and browser pairs Google Maps supports (such as NVDA or JAWS with Firefox, and VoiceOver with Safari), but support varies by feature, so design for the panel to carry everything.

  • Reduced motion: when prefers-reduced-motion: reduce is set, jump or cross-fade instead of the long fly animation, keep zoom steps short, and disable inertia that keeps the map moving after release.
  • Color is not the only signal: traffic uses colors, so the route list also says "heavy traffic, 12 min delay" in text, and the traffic line can use a pattern for jams.
  • Zoom and text size: the panel text reflows with browser zoom up to 400 percent; map labels have a size setting in the style because they do not follow the page font size.

Internationalization

  • Place names. Tiles carry a local name plus translated names (name, name:en, name:ja). The style picks the user's language with a fallback to the local name, for example ["coalesce", ["get", "name:fr"], ["get", "name"]]. Many maps show both for cities in other scripts so users can match street signs.
  • Units. Distances in kilometres or miles depend on the region and the user's setting, not the language: an English speaker in Germany may want kilometres. Format with Intl.NumberFormat and style: "unit" (unit: "kilometer" or "mile"), and durations with care, since "4 h 41 min" is written differently per language. The locale formatting lesson covers the Intl APIs.
  • Right-to-left. In Arabic or Hebrew the app shell mirrors: the panel moves to the right, icons with direction flip, and padding passed to the engine flips with it. The map does not mirror: north stays up and east stays right.
  • Text rendering on the map. Our renderer draws text, so it must handle what the browser normally does: bidirectional text, Arabic letter joining, and scripts like Devanagari or Thai that need shaping. Glyphs load per script range only when visible. MapLibre, for example, loads an optional plugin for right-to-left text.
  • Borders and names that depend on the viewer. Some borders and place names are disputed, and map providers show them differently by region. Make the "worldview" a style parameter chosen by the server, not a client decision.

Check your understanding · Exercise 14

A blind user searches for pharmacies and wants the closest open one. Which parts of the design make that possible? Select all that apply.

Running it in the interview

This question can swallow an hour on any one part. Your job is to show breadth in the first half and depth on the map engine in the second, and to let the interviewer steer. A plan for 60 minutes:

MinutesWhat to doWhat to draw or say
0 to 6ScopeBuild the renderer or use an SDK? Devices, features, marker counts, shareable links. Write the numbers down: 60 frames per second, 12 to 35 tiles per view, 10,000 markers.
6 to 12Wireframe and architectureThe screen with panel and map; then the engine, workers, tile cache, store, URL sync, API client. Say why the camera is outside React.
12 to 25Tiles and renderingWeb Mercator, the z/x/y pyramid with the tile math, raster versus vector, WebGL, layers, LRU cache, parent placeholders.
25 to 33GesturesCamera model, requestAnimationFrame loop, zoom around the cursor (write the formula), inertia, wheel with ctrlKey.
33 to 45Markers, search, directionsHybrid markers with clustering; search with viewport bias and "Search this area"; encoded polyline and traffic segments.
45 to 55URL, performance, accessibilityPush versus replace; static map image for LCP and lazy chunks; the panel as the accessible view.
55 to 60Wrap upName what you would measure and what you skipped.

Draw the wireframe and the architecture early, even roughly, so later details have somewhere to attach. Then go deep where the interviewer leans in: tiles and labels for a rendering-minded interviewer, search, directions, and the URL for a product-minded one.

Follow-up questions and strong answers

Think first

What happens if WebGL is not available, or the GPU context is lost mid-session?

Think first

How would you show a friend's live location, or a delivery driver, moving on this map?

Think first

The interviewer asks for 3D buildings when tilted. What changes?

  • "How do you test this?" Unit test the math (projection, tile cover, zoom around a point, polyline decode) with known values like the ones in this guide. Screenshot test the renderer with fixed styles and tiles in a headless browser with WebGL. End to end test URL round trips: open a link, check the panel and camera, press Back.
  • "How do you know it is fast for real users?" Real user monitoring of LCP and INP on the shell, plus custom metrics: time to first map frame, frame time percentiles during pans, tile error rates, and WebGL context losses, split by device class.
  • "What would you cut with half the time?" Use an open source renderer such as MapLibre GL JS for the engine, and spend the time on search, directions, URL state, and accessibility.

Check your understanding · Exercise 15

You have 10 minutes left and have not covered the URL or accessibility. Which are the strongest moves? Select all that apply.

Summary

  • Web Mercator turns the planet into a square; the z/x/y pyramid cuts it into 4^z tiles per zoom, and simple math gives the 12 to 35 tiles any view needs.
  • Vector tiles drawn with WebGL give rotation, tilt, smooth zoom, client-side styles, and upright labels, at the cost of a heavy client with label collision; keep raster for satellite and as a fallback.
  • Decode tiles in workers, keep decoded tiles in a bounded LRU cache, and never show a blank square: draw the nearest loaded parent or children until the real tile arrives.
  • Gestures only change a camera; one requestAnimationFrame callback draws it. Zoom around the cursor by keeping the world point under it fixed, and add inertia from the last 100 ms of movement.
  • Draw many markers in the canvas with hierarchical clustering in a worker, render only the selected one as DOM, and hit test with a screen-space index.
  • Search is biased by the viewport; results live once in the store and drive both list and markers; moving the map offers "Search this area" instead of replacing results.
  • Routes travel as encoded polylines, are simplified per zoom, and carry traffic as index ranges colored on the line, with a text step list in the panel.
  • The URL is the source of truth for view, place, search, and route: replace while moving, push on meaning.
  • First view: server-rendered shell and static map image, preloaded engine and tiles, everything else lazy. Offline on the web means surviving drops, not downloading areas.
  • The map is never the only way to get information: the panel carries everything as text, and the map is keyboard operable.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next