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.
| Question | Answer we assume | What 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Think first
When the user searches for "pizza", should the map move to fit the results, or should the results fit the map?
Both, at different moments. A new typed query searches around the current view and fits the map to the results if they spill outside it. Once the user moves the map by hand, the app waits for them to press "Search this area".
The rule behind it: move the map only when the user asks, and never throw away results the user is reading. The search chapter designs this.
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.
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):
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 };
}
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.
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.| Zoom | Tiles in the world | Metres per pixel at the equator | What you see |
|---|---|---|---|
| 0 | 1 | about 156,543 | The whole world in one tile |
| 5 | 1,024 | about 4,892 | A large country |
| 10 | about 1 million | about 153 | A metro area |
| 14 | about 268 million | about 9.6 | Neighbourhood streets |
| 18 | about 69 billion | about 0.6 | Buildings and house numbers |
| 21 | about 4.4 trillion | about 0.07 | Close 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).
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;
}
256 or 512 pixel tiles?
Decision How big should one tile be?
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?
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.
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
motorwayappears 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.
{
"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]
}
}
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:
- 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).
- 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.
- 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.
- 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?
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:
- 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.
- A worker calls
fetchfor 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 asFloat32Array. - The worker posts the arrays back to the main thread as transferable objects, which moves the memory without copying it.
- 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.
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.
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.
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);
}
}
}
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.
First load: 0 from cache, 12 new requests.
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.
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
};
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. CallsetPointerCaptureon down so the drag keeps working when the pointer leaves the map or crosses an overlay. - Set
touch-action: noneon 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
cxinto 0 to 1 so the user can pan east forever, and clampcyso 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.
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),
};
}
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
wheelevents withctrlKeyset to true and smalldeltaYvalues; map them to a continuous zoom such asdz = -deltaY * 0.01. Safari on macOS instead fires its own non-standardgesturestartandgesturechangeevents with ascale, so handle both. Register the listener with{ passive: false }and callpreventDefault(), or the browser zooms the whole page. - Trackpad two-finger scroll: arrives as plain
wheelwith 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.
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?
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?
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.
// 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 });
}
};
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 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.
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.
Search: autocomplete, results, and the map in sync
On a map, where is part of every query: "pizza" means pizza near what you are looking at. So the viewport goes into every request, and results show twice: as a list in the panel and as markers on the map.
Autocomplete biased by the viewport
The search box is an autocomplete, and the autocomplete guide covers its mechanics in depth: debounce input by about 100 to 150 ms, cancel stale requests with AbortController, ignore out-of-order replies, cache recent prefixes, and build the listbox with the ARIA combobox pattern. On a map, three things are added:
- Location bias. Each request carries the current view, as a rectangle or a center and radius. "Main St" then suggests the Main Street in this town first. Bias is not a filter: a typed "Eiffel Tower" from New York still finds Paris.
- Mixed suggestion types. Places (a business), addresses, categories ("coffee"), and past searches, each with an icon and a secondary line such as the city, so users can tell two "Springfield" results apart.
- Session tokens. Google's Places API bills autocomplete by session: the client creates a token when the user starts typing, sends it with every autocomplete request and with the final place details request, then drops it. Those requests are grouped and billed as one session. We design our API the same way, so the server can treat one search, from first keystroke to the chosen place, as one unit. The uber guide covers the billing side.
The client cache key for suggestions must include the bias, rounded so it actually gets hits: the query plus the view's center rounded to about 0.01 degrees (about 1 km) and the zoom rounded to an integer. Otherwise every tiny pan would miss the cache.
Results panel and markers, one source of truth
Pressing Enter or picking a category runs a full search. The response is a page of 20 places with ids, names, positions, ratings, and a few fields for the list. The list and the markers must never disagree, so both read from one store entry, keyed by the search's identity: query plus area. Neither keeps its own copy.
- Hover a list item and its marker grows and moves to the top; hover a marker and its list item is highlighted. Both write one
hoveredPlaceIdto the store. - Click either one and the place opens in the panel, its id goes into the URL, and the map pans just enough to show it beside the panel, not under it.
- Fit to results only for a new query: if some results fall outside the view, animate the camera to their bounds, with padding equal to the panel's width on the left so no marker hides behind it.
- Markers carry rank. Result 1 has higher priority in label collision than result 18, so when they overlap the better one stays visible.
Decision What happens to results when the user pans the map away?
What we pick: The button by default, which is what Google Maps does for place searches; its desktop site also has an "Update results when map moves" checkbox for users who want auto-search. On a maps product people pan to look around a result, check a street, or measure distance, and they do not expect the list to change. The "show the button" rule should use overlap, not any movement, so a nudge of 50 pixels does not nag.
The sequence below follows "Search this area" from click to new markers, including the failure path: a slow request that the user overtakes by panning and pressing again, and a request that fails. The request races lesson covers the general pattern of aborting and ignoring stale replies.
let current: AbortController | null = null;
async function searchArea(query: string, bounds: Bounds) {
current?.abort(); // a newer search wins
const ctrl = (current = new AbortController());
store.setSearch({ status: "loading", query, bounds }); // keep old results visible, dimmed
try {
const res = await api.search({ query, bounds }, { signal: ctrl.signal });
if (ctrl !== current) return; // belt and braces against late replies
store.setSearch({ status: "ready", query, bounds, results: res.places });
url.replace({ query, bounds }); // the search is now shareable
} catch (err) {
if (ctrl.signal.aborted) return; // superseded, not an error
store.setSearch({ status: "error", query, bounds, retry: () => searchArea(query, bounds) });
}
}
Check your understanding · Exercise 7
A user searches "bakery" in Lyon, then pans 200 km to Geneva while reading the list. What should the app do? 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:
- 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.
- 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.
- 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
0x20bit 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.
_ulL.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]]
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.
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:
| State | In the URL? | Example | Why |
|---|---|---|---|
| Center and zoom | Yes | @48.8584,2.2945,15z | The minimum for a shared view to make sense. |
| Bearing and tilt | Yes, only when not zero | ,30h,45t | A rotated 3D view should reopen rotated, but the common case stays short. |
| Open place | Yes | /place/eiffel-tower/p_8f3k2q | A stable id makes the link work; the readable name is for humans and is ignored when parsing. |
| Search query and searched area | Yes | /search/bakery/@... | A shared search must show the same results, so the area is part of it. |
| Directions | Yes | /dir/{origin}/{destination}?mode=transit&alt=1 | The route and the chosen alternative are what the person wants to share. |
| Layers (traffic, satellite) | Yes | ?layer=traffic | Changes what the map means. |
| Hovered result, open menus, panel scroll | No | Kept in component state | Moment-to-moment UI state: noise in history and links. |
| Session token, request ids | No | Kept in memory only | Private, 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.replaceStatewith the new@lat,lng,zoom. Throttle it: browsers limit how often history can be changed, and Safari throws aSecurityErrorafter 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.
pointermove, makes Back useless.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);
});
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:
- 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.
- 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.
- On demand. More photos when the user scrolls the strip, reviews when the reviews section approaches the viewport (an
IntersectionObserverwith arootMarginof 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
srcsetfor 1x and 2x density, in AVIF or WebP. The image optimization lesson covers formats,srcset, andsizesin depth. - Lazy load with
loading="lazy"on everything past the first two thumbnails, and give each image fixedwidthandheightso 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?
Show the cached copy at once, and revalidate in the background if it is older than a short time, say a minute or two. Details such as hours and "open now" change rarely; the rating count changes slowly. This is stale-while-revalidate at the store level, the same idea as the HTTP header.
Keep the last 20 or so places in the cache and evict the rest, like tiles. A long session can open hundreds of places.
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.
| Part | Runs where | Responsibility |
|---|---|---|
| App shell | Main thread, React | Search box, side panel (results, place, directions), controls, dialogs. Reads the store, never touches the GPU. |
| Map engine | Main thread, plain TypeScript class | Owns 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 Workers | Fetch, decode, style-filter, and triangulate tiles; cluster points; decode and simplify routes. |
| Tile cache | Main thread memory and GPU | LRU of decoded tiles with their GPU buffers; placeholder lookup by ancestor and children. |
| Store | Main thread | Server data (places, searches, routes) keyed by request, plus UI state (selected place, hovered id, panel mode). |
| URL sync | Main thread | Parses the URL into the store on load and on popstate; writes the store back with push or replace. |
| API client | Main thread | Typed requests for search, autocomplete, places, directions; abort, retry with backoff, and error mapping. |
| Service worker | Separate thread | Caches the app shell, fonts, glyphs, and sprites; a small runtime cache of recently viewed tiles where the terms allow. |
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.
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.
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 }[];
};
| Data | Lives in | Lifetime | Why there |
|---|---|---|---|
| Camera | Map engine (memory) | Session; settled value mirrored to the URL | Changes every frame; only the engine needs it at that rate. |
| Decoded tiles and GPU buffers | Tile cache (LRU) | Until evicted | Large and binary; bounded by a memory budget. |
| Raw tile bytes | Browser HTTP cache (disk) | Days for base tiles, about a minute for traffic | The browser already does this well with Cache-Control. |
| Places | Store, normalized by id | Session, last N entries | Shared by markers, list, and panel. |
| Searches and routes | Store, keyed by request | Minutes | Instant back and forth between modes and results. |
| Selected place, query, route endpoints, layers | URL, mirrored into the store | As long as the link exists | Shareable and restored by Back. |
| Hovered id, panel scroll, sheet height | Component or UI store | Moments | Not worth sharing or persisting. |
| Recent searches, saved places, home and work | Server account, cached in IndexedDB | Long-term | Shown before the network answers; signed-in users see them on every device. |
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
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
- 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
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
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" }
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
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
}
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.
- Server-rendered shell with a static map image. The HTML response already contains the panel (for a place link: the place header) and an
imgof 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. - 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=preloadormodulepreloadhints, so tiles download while the engine parses. - 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.
- 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.
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
| Work | Where | Budget per frame | How to keep it there |
|---|---|---|---|
| Input handling | Main thread | Under 1 ms | Handlers only update the camera; no layout reads. |
| Tile selection and placeholder lookup | Main thread | About 1 ms | Pure math on a few dozen keys. |
| GPU uploads of new tiles | Main thread | 2 to 4 ms | At most a couple of tiles per frame; queue the rest for the next frames. |
| Label placement | Main thread | 0 ms on most frames | Rerun every 300 ms during motion, or when the camera idles. |
| Draw calls | Main thread to GPU | 2 to 5 ms | Few draw calls per layer; batch by texture; cap device pixel ratio at 2. |
| Decoding, triangulating, clustering | Workers | Not on the frame | Never on the main thread. |
| React panels | Main thread | 0 ms during a pan | Panels 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-liveregion 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 invisiblebuttonper 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
buttonelements with names andaria-pressedfor 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: reduceis 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.NumberFormatandstyle: "unit"(unit: "kilometer"or"mile"), and durations with care, since "4 h 41 min" is written differently per language. The locale formatting lesson covers theIntlAPIs. - 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:
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope | Build 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 12 | Wireframe and architecture | The 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 25 | Tiles and rendering | Web Mercator, the z/x/y pyramid with the tile math, raster versus vector, WebGL, layers, LRU cache, parent placeholders. |
| 25 to 33 | Gestures | Camera model, requestAnimationFrame loop, zoom around the cursor (write the formula), inertia, wheel with ctrlKey. |
| 33 to 45 | Markers, search, directions | Hybrid markers with clustering; search with viewport bias and "Search this area"; encoded polyline and traffic segments. |
| 45 to 55 | URL, performance, accessibility | Push versus replace; static map image for LCP and lazy chunks; the panel as the accessible view. |
| 55 to 60 | Wrap up | Name 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?
At startup, feature-detect WebGL 2 (then WebGL 1). If neither works, fall back to raster tiles drawn as images, with no rotation or tilt: this is what Google did with Lite mode.
Mid-session, listen for webglcontextlost, call preventDefault() so the context can be restored, pause the frame loop, and on webglcontextrestored re-create shaders and re-upload buffers from the decoded tiles in the cache. The user sees a brief freeze, not a blank map.
Think first
How would you show a friend's live location, or a delivery driver, moving on this map?
A small point layer fed by a WebSocket or server-sent events with positions every few seconds. Interpolate between the last two positions over the update interval, snapping to the road geometry if available, so the marker glides instead of jumping. The uber guide covers this in depth.
The point layer is just another engine layer, so the camera, label collision, and hit testing already work for it.
Think first
The interviewer asks for 3D buildings when tilted. What changes?
Tiles carry building footprints with a height property. The worker extrudes them into 3D boxes (walls and roofs) and the renderer needs a depth buffer and a perspective camera with pitch. With tilt, the far part of the screen shows much more ground, so the visible tile set is a trapezoid and distant areas use lower zoom tiles to keep the count bounded.
- "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.
References
- Mapbox Vector Tile specification The open vector tile format: layers, features, geometry commands, extent.
- Slippy map tilenames (OpenStreetMap wiki) The z/x/y scheme with formulas and code in many languages.
- Encoded Polyline Algorithm Format (Google) The step by step encoding with the worked example used here.
- supercluster Hierarchical point clustering with a KD-tree per zoom.
- MapLibre GL JS An open source WebGL vector map renderer worth reading: tiles, workers, labels, styles.
- MapLibre style specification Layers, filters, and zoom expressions for client-side styling.
- Pointer events (MDN) One input model for mouse, pen, and touch, with pointer capture.
- WebGL API (MDN) The drawing API behind vector maps, including context loss events.
- Web Workers API (MDN) Moving tile decoding and clustering off the main thread.
- Largest Contentful Paint (web.dev) The metric the static map image is there to improve.
- Maps URLs (Google Maps Platform) The documented, stable way for other sites to link into Google Maps.
- Map Tiles API policies (Google Maps Platform) What licensed tiles allow: caching limits, Cache-Control, and no offline use.
- Accessibility in Google Maps (Google Help) Keyboard controls and supported screen readers on google.com/maps.
Concepts behind it
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- In-memory cachesData your app keeps as live JavaScript objects in the tab's memory, keyed and bounded, so that going back, switching tabs, or opening the same thing twice is instant: the fastest cache you have, and the easiest one to let grow out of control.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.