Esri World Imagery Wayback, the dated releases (wayback.maptiles.arcgis.com)
How Esri's World Imagery Wayback viewer works, its release-chained dates against Google Earth Historical's per-tile ones, and how Terrain Viewer shows them. The service is Esri's at wayback.maptiles.arcgis.com, often typed as wayback.maptiler.com, which does not exist.
ESRI Wayback is one of the providers behind Historical Imagery mode. Where Google Earth Historical is self-contained per-tile — every capture date for a tile lives inside that one tile's quadtree packet — Wayback is release-chained: a flat, dated catalog of full, independently-addressable global tile pyramids. lib/wayback.ts is built on @esri/wayback-core (the SDK behind Esri's own Wayback app) and resolves that release model into the same "one date in, one tile source out" shape every other historical provider in this app already has.
The service itself is Esri's, at wayback.maptiles.arcgis.com (/arcgis/rest/services/World_Imagery/MapServer, with WMTS/1.0.0/default028mm/{release}/{z}/{y}/{x} tiles and the release catalog at waybackconfig.json). It is sometimes looked up as "wayback.maptiler.com", which does not exist: MapTiler has no Wayback service; the "maptiles" in the host is Esri's tile subdomain.
Three different dates, easy to conflate
Wayback surfaces (and this app displays) three distinct dates for the same pixel:
- Release date — the date a Wayback release was published (e.g.
"World Imagery (Wayback 2014-02-20)"), parsed from the catalog entry's title. This is what the sidebar list and the timeline's release ticks show — it's a publish date, not a capture date. - Mosaic composition date — Esri's own general "as-of" date for a release's global mosaic, which can lag or blend imagery captured well before the release date, and varies by location (a release is composited from many underlying source images stitched together, not one single flight/pass).
- Imagery capture date — the real per-tile date the pixels under your cursor were actually photographed, queried live from Esri's own World Imagery MapServer identify endpoint on click/hover (
fetchWaybackCaptureMeta()inlib/wayback.ts, wrapping the samegetMetadatacall the official Wayback app's own capture-date pill uses). This is the one date that's authoritative for what you're actually looking at — it can differ from the release date by months, and this app always prefers it over the release date whenever it's available (seeuseWaybackRealCaptureDates/useEsriLiveCaptureDatebelow).
What a "release" is
@esri/wayback-core fetches a single JSON catalog once, waybackconfig.json, keyed by release number:
https://s3-us-west-2.amazonaws.com/config.maptiles.arcgis.com/waybackconfig.jsonEach entry gives a WMTS-style tile template ({level}/{row}/{col}, host maptiles.arcgis.com) and a title like "World Imagery (Wayback 2014-02-20)", parsed into a release date. A release is a complete, standalone global mosaic — there's no per-tile date at the catalog level, only at the release level. waybackTileUrl() in lib/wayback.ts is the one fixup this app needs before handing a release to MapLibre — WMTS placeholders to XYZ:
export function waybackTileUrl(item: WaybackItem): string {
return item.itemURL.replace("{level}", "{z}").replace("{row}", "{y}").replace("{col}", "{x}")
}The result is added as an ordinary XYZ raster source ({ tiles: [waybackTileUrl(item)], tileSize: 256, maxzoom: 19 } in MapSources.tsx) — no addProtocol, no custom decoding, unlike GE Historical's gehist:// handler.
Discovery and caching
The release catalog is fetched once per app session via a module-level cached promise (cachedItemsPromise in lib/wayback.ts), shared by the sidebar list, the timeline panel, and both split-view A/B basemap sources — not once per component instance. There's no hardcoded fallback list; a fetch failure just leaves an empty release array.
Local-changes filtering. Most releases repeat the same imagery as their neighbor at any given spot — a release only matters at a location if it actually changed something there. lib/wayback.ts filters the full catalog down to releases with a genuinely distinct tile at that exact location, with the same rules as the SDK's getWaybackItemsWithLocalChanges(location, zoom, { onlyUseSizeToFilterDuplicates: true }). The next section covers how it does so without making the user wait.
Showing a place quickly
Finding a place's releases and dating them takes dozens of requests, and neither can be done in one call. The timeline therefore shows what it knows as it learns it, under one rule: nothing is drawn that will have to be taken back.
| Step | Requests | On the timeline |
|---|---|---|
| Look up the place in local storage | none | Known ticks at their real dates, solid |
| Walk the releases, eight chains at once | About 25 tilemap calls | A dashed tick at the release date, once the release is known to stay |
| Ask for each release's imagery date | one or more getMetadata calls per release | The tick turns solid and moves to its imagery date |
| Save the walk and the dates | none | The next visit starts at the first row |
What the SDK gives
@esri/wayback-core supplies the catalog, the metadata and the tile maths. The app leaves out one call, the SDK's walk:
| SDK call | In this app |
|---|---|
getWaybackItems() | The catalog, fetched once per session |
getMetadata(point, zoom, release) | A release's imagery date, source and resolution at a place |
getWaybackServiceBaseURL(), long2tile(), lat2tile() | Building the tilemap requests |
getWaybackItemsWithLocalChanges() | Not used. It walks one chain and only answers at the end, with no ticks until then |
Without onlyUseSizeToFilterDuplicates, the SDK also downloads tile images to compare same-size neighbours byte by byte. The app relies on size alone, as that option does, and so saves those downloads. The cost is that two different images of exactly the same size count as one.
Walking in parallel
A tilemap/{release}/{level}/{row}/{col} answer gives the release holding that tile's imagery (select), its size in bytes (size) and whether a tile exists at all (data). The next release to ask is the one just older than select. Each answer names the next question, so a single chain has to run in order, request after request, with more of them at a place with many releases.
The chain can still be started from several places. startWalk starts eight, evenly spread across the catalog:
const walkFrom = async (start: number) => {
for (let i = start; i < catalog.length;) {
const asking = catalog[i].releaseNum
if (asked.has(asking)) return // another chain has this one
asked.add(asking)
const answer = await askTilemap(asking) // { releaseNum, size }, or null with no tile here
answers.set(asking, answer)
const reached = !!answer && found.has(answer.releaseNum)
if (answer) found.set(answer.releaseNum, answer)
const at = answer ? indexOf.get(answer.releaseNum) : undefined
if (reached || at === undefined || at < i) return // met another chain, or ran out
i = at + 1
}
}
await Promise.all(Array.from({ length: 8 }, (_, n) => walkFrom(Math.floor((n * catalog.length) / 8))))A chain stops where it meets a release another chain has reached. The result isn't simply everything found. A walk from the newest release stops at the first release with no tile, where a chain started further down may carry on. So the final list is rebuilt by following answers from the newest release, as if one chain had walked alone (chainFromNewest).
Over 14 places, eight chains found the same releases as one chain, in 15.6 s instead of 40.8 s in all. That took 330 requests instead of 226: about 2.6 times faster, for about 1.5 times the requests.
A tick only once it stays
Same-size neighbours count as one image, kept at its oldest release, the first to carry that image. A release the walk has just found may therefore give way to an older one, and drawing it straight away means taking it back. When every find was drawn, a place with 13 releases showed 30 ticks on load, the other 17 hopping or vanishing.
So a release gets a tick only once the next older release has answered with another size or no tile, or when it is the oldest in the catalog:
const isFinal = (f: Found) => {
if (level <= 11) return true // the SDK does not merge neighbours at these levels either
const at = indexOf.get(f.releaseNum)
if (at === undefined) return false
if (at + 1 >= catalog.length) return true
const older = answers.get(catalog[at + 1].releaseNum)
return older === null || (older !== undefined && older.size !== f.size)
}Dashed, then dated
A dashed tick stands at its release date, the only date known without asking, and is marked as provisional. When its imagery date arrives from the metadata service it turns solid and moves there. That one move is by design, so a tick appears once and moves at most once. After that, a few ticks can still go: two releases can turn out to hold imagery of the same date, and the timeline keeps one tick for them.
Picking a dashed tick picks that release, and the view's date follows it when the real date arrives.
Esri's metadata has holes: a release can have no dated record at one level and a dated one at a finer level, so up to three finer levels are asked. A release with no date at any of them stays dashed, and its tooltip says Esri gives no date.
Dates belong to a place, since the same release has another date elsewhere. The previous place's dates are never applied to a new place's ticks, which would stand at the wrong date and then jump.
The axis holds still
While the walk and the dates load, a view on ESRI keeps its handle at its own date. With ESRI's ticks on, the timeline keeps its range, from 2010 to today at first, until the user clicks it, zooms it or changes a pill. Ticks outside that range stay hidden until then. This is what stops the handle and the axis from jumping as each date lands.
Between sessions
Walks and imagery dates are kept in local storage (waybackDates.v1), 300 places at most, about 130 KB. A release's imagery date at a place never changes, and a walk only ages when a new release comes out, so a place seen before sends no request, and its ticks start solid at their real dates.
Date → release resolution
Every other historical source in this app takes a plain date directly. Wayback releases are addressed by release number, a foreign key into Esri's own catalog — so useResolvedWaybackRelease() is the one place that gets resolved, snapping a state.dateA/dateB timestamp to whichever release's real per-tile capture date (not the catalog-wide release date — a single release mosaics tiles captured at many different times) is closest, in either direction:
const item = useMemo(() => {
if (!targetDateMs || !items.length) return null
let best: WaybackItem | null = null, bestDist = Infinity
for (const it of items) {
const realDateMs = resolved[it.releaseNum]?.dateMs ?? it.releaseDatetime
const dist = Math.abs(realDateMs - targetDateMs)
if (dist < bestDist) { bestDist = dist; best = it }
}
return best
}, [items, resolved, targetDateMs])Real per-tile dates come from Esri's metadata feature service (getMetadata), queried per release per location and cached; a release whose real date hasn't resolved yet falls back to its own catalog-wide releaseDatetime so the nearest-release search always has some value to compare against.
Progressive resolution, not all-or-nothing
useWaybackRealCaptureDates resolves each release's real date independently as its own request completes, rather than behind a single Promise.all. Esri's metadata endpoint has no AbortSignal/cancellation support at all, so a single slow response used to block every other already-resolved release from showing too. Timeline ticks now populate progressively instead of all-or-nothing. A request that starts can't be cancelled once in flight, so the walk's own 400 ms debounce (matching the app's viewStateUpdateTimer settle cadence) keeps a passing view from starting any; the lookups add no second one.
Live "ESRI World Imagery" vs. Wayback
The plain, non-historical "ESRI World Imagery" basemap (id esri) has no date field of its own — but it isn't dateless, it's just always showing whichever Wayback release is currently newest at a location, since Wayback's newest release is the live World Imagery basemap, republished under its own catalog. useEsriLiveCaptureDate() waits for the walk to end, since the newest release kept so far may not be the newest there is, then resolves items[0] (newest-first) through the same real-capture-date machinery as any other tick, just permanently pinned to the newest release rather than a user-picked date.
Attribution
useWaybackDynamicAttribution() composes real per-release attribution from the same getMetadata call already used for capture dates (provider, source, resolution, accuracy — previously discarded) into the two-field shape Esri's own World_Imagery MapServer "identify" operation returns for a clicked point (a short SRC_DESC code and a full descriptive NICE_DESC sentence). This replaced sharing the generic "who covers this region today" contributor-coverage feed between the live basemap and every dated Wayback tick, which had made Wayback attribution always resolve to whichever provider covers the region today regardless of which historical date was actually selected.
Batch export
listWaybackTicksInRange() is the non-hook equivalent used by lib/export-multi.ts to enumerate every real Wayback capture within a date range at a location, outside of a mounted component — resolving each candidate's real date sequentially rather than in parallel, since export already fans out per feature/source/date itself and this favors going easy on Esri's metadata endpoint over raw speed.
Caveats
- Esri's metadata service only supports zoom 10–23 (clamped internally by
@esri/wayback-core). - No request cancellation on the metadata endpoint — see the progressive-resolution callout above.
onlyUseSizeToFilterDuplicates: truecan, in principle, miss a release that's genuinely different but happens to produce a same-size image at the compared tile.