fix(offline): throttle the map tile prefetch

The prefetch dispatched every tile of a trip's bounding box in one
synchronous loop — up to MAX_TILES per trip. A trip spanning a few
countries enumerates thousands of tiles at zoom 10 alone, so logging in
fired ~8k no-cors requests back to back and kept them coming for half a
minute. All of them go through the Workbox fetch handler, which then has
to write a cache entry plus an expiration record per tile, so everything
the app itself requested queued up behind the burst and the UI just sat
there.

Run the queue through a small worker pool instead, and let it stop early
when the user goes offline or logs out.

Two things fall out of that:

- Tiles already in Cache Storage are now skipped before the request is
  made, so a resumed or repeated prefetch over a warm cache is nearly
  free.
- {s} has to be a pure function of the tile coordinates for that lookup
  to hit. The rotating counter gave the same tile a different host on
  every run, which also meant caching it up to four times.
This commit is contained in:
Maurice
2026-07-26 00:20:59 +02:00
committed by Maurice
parent a3e2ff9fa5
commit 3c67afeade
2 changed files with 310 additions and 31 deletions
+136 -31
View File
@@ -6,7 +6,13 @@
* 1. Compute bbox from trip's place coordinates + padding.
* 2. For zooms 10–16, enumerate tile XYZ coordinates within bbox.
* 3. Stop when cumulative tile estimate exceeds MAX_TILES (~50 MB).
* 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it.
* 4. Fetch each tile URL so the Service Worker CacheFirst handler caches it,
* at most TILE_CONCURRENCY at a time.
*
* The throttle is the important part: a wide trip enumerates thousands of
* tiles, and dispatching them all at once buries every request the app itself
* makes behind them in the Service Worker's fetch queue — the UI then sits on
* a blank screen until the burst drains.
*
* Tile URL template format: Leaflet-compatible {z}/{x}/{y} with optional
* {s} (subdomain) and {r} (retina suffix).
@@ -14,6 +20,7 @@
import type { Place } from '../types'
import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
import { isAuthed } from './authGate'
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -30,13 +37,32 @@ const AVG_TILE_KB = 15
*/
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
/**
* Tile requests in flight at once.
*
* Low on purpose: these are background downloads competing with the live app
* for the Service Worker's fetch handler, the connection pool and the origin's
* IndexedDB queue (Workbox writes an expiration record per cached tile).
*/
export const TILE_CONCURRENCY = 6
/** Name of the Workbox runtime cache holding map tiles (see vite.config.js). */
const TILE_CACHE = 'map-tiles'
const DEFAULT_TILE_URL =
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const SUBDOMAINS = ['a', 'b', 'c', 'd']
let _subIdx = 0
function nextSubdomain(): string {
return SUBDOMAINS[_subIdx++ % SUBDOMAINS.length]
/**
* Pick the subdomain from the tile coordinates, the way Leaflet does.
*
* It has to be a pure function of x/y: a rotating counter hands the same tile a
* different host on every run, which both defeats the cache lookup below and
* stores the tile up to four times under four URLs.
*/
function subdomainFor(x: number, y: number): string {
return SUBDOMAINS[Math.abs(x + y) % SUBDOMAINS.length]
}
// ── Tile math ──────────────────────────────────────────────────────────────────
@@ -126,10 +152,47 @@ export function buildTileUrl(template: string, z: number, x: number, y: number):
.replace('{z}', String(z))
.replace('{x}', String(x))
.replace('{y}', String(y))
.replace('{s}', nextSubdomain())
.replace('{s}', subdomainFor(x, y))
.replace('{r}', '')
}
/**
* Enumerate the tile coordinates to prefetch, lowest zoom first, stopping at
* the zoom level whose tiles would push the total past MAX_TILES.
*/
function enumerateTiles(
bbox: TileBbox,
minZoom: number,
maxZoom: number,
): Array<[z: number, x: number, y: number]> {
const coords: Array<[number, number, number]> = []
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
const maxX = lngToTileX(bbox.maxLng, z)
const minY = latToTileY(bbox.maxLat, z)
const maxY = latToTileY(bbox.minLat, z)
if (coords.length + (maxX - minX + 1) * (maxY - minY + 1) > MAX_TILES) break
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) coords.push([z, x, y])
}
}
return coords
}
/** Open the tile cache, or null where Cache Storage isn't available. */
async function openTileCache(): Promise<Cache | null> {
try {
if (typeof caches === 'undefined') return null
return await caches.open(TILE_CACHE)
} catch {
return null
}
}
/**
* Prefetch tiles for a bbox into the Service Worker cache.
* Stops at the zoom level where the size cap would be exceeded.
@@ -137,45 +200,54 @@ export function buildTileUrl(template: string, z: number, x: number, y: number):
* - offline
* - no active Service Worker (tiles won't be cached anyway)
* - total tile count exceeds MAX_TILES before even starting zoom 10
*
* Resolves once every tile has been dealt with, so callers that need the tiles
* on disk ("prepare for offline") can simply await it. Callers that don't
* (background sync) leave the promise floating.
*
* Returns the number of tiles actually fetched — tiles already in the cache are
* skipped without touching the network.
*/
export async function prefetchTiles(
bbox: TileBbox,
tileUrlTemplate: string,
minZoom = 10,
maxZoom = 16,
awaitAll = false,
): Promise<number> {
if (!navigator.onLine) return 0
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return 0
const coords = enumerateTiles(bbox, minZoom, maxZoom)
if (coords.length === 0) return 0
// Checking Cache Storage from here is far cheaper than letting the request
// reach the SW's CacheFirst handler, so a resumed or repeated prefetch over a
// warm cache costs almost nothing.
const cache = await openTileCache()
let cursor = 0
let fetched = 0
// When awaitAll is set (the "prepare for offline" path), we wait for every tile
// request to settle so the caller's progress bar only completes once the tiles
// are actually downloaded into the SW cache — not merely dispatched.
const inflight: Promise<unknown>[] = []
for (let z = minZoom; z <= maxZoom; z++) {
const minX = lngToTileX(bbox.minLng, z)
const maxX = lngToTileX(bbox.maxLng, z)
const minY = latToTileY(bbox.maxLat, z)
const maxY = latToTileY(bbox.minLat, z)
const count = (maxX - minX + 1) * (maxY - minY + 1)
async function worker(): Promise<void> {
while (cursor < coords.length) {
// Going offline or logging out mid-run abandons the rest of the queue.
if (!navigator.onLine || !isAuthed()) return
if (fetched + count > MAX_TILES) break
const [z, x, y] = coords[cursor++]
const url = buildTileUrl(tileUrlTemplate, z, x, y)
for (let x = minX; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
const url = buildTileUrl(tileUrlTemplate, z, x, y)
// SW CacheFirst handler stores the response. Fire-and-forget unless the
// caller asked to await completion.
const p = fetch(url, { mode: 'no-cors' }).catch(() => {})
if (awaitAll) inflight.push(p)
fetched++
}
if (cache && (await cache.match(url))) continue
// The SW CacheFirst handler stores the response.
await fetch(url, { mode: 'no-cors' }).catch(() => {})
fetched++
}
}
if (awaitAll && inflight.length) await Promise.allSettled(inflight)
await Promise.all(
Array.from({ length: Math.min(TILE_CONCURRENCY, coords.length) }, worker),
)
return fetched
}
@@ -186,26 +258,59 @@ export async function prefetchTiles(
*/
export async function clearTileCache(): Promise<void> {
try {
if (typeof caches !== 'undefined') await caches.delete('map-tiles')
if (typeof caches !== 'undefined') await caches.delete(TILE_CACHE)
} catch {
/* Cache Storage unavailable (no SW / private mode) — nothing to clear */
}
// Drop the recorded bboxes too, otherwise prefetchTilesForTrip would consider
// these trips done and never refill the cache we just emptied.
try {
const metas = await offlineDb.syncMeta.toArray()
await Promise.all(
metas
.filter(m => m.tilesBbox !== null)
.map(m => upsertSyncMeta({ ...m, tilesBbox: null })),
)
} catch (err) {
console.error('[tilePrefetch] failed to reset tile bboxes:', err)
}
}
/** Same bbox to within ~1 m — i.e. the trip's places haven't moved. */
function sameBbox(a: [number, number, number, number], b: TileBbox): boolean {
return (
Math.abs(a[0] - b.minLng) < 1e-5 &&
Math.abs(a[1] - b.minLat) < 1e-5 &&
Math.abs(a[2] - b.maxLng) < 1e-5 &&
Math.abs(a[3] - b.maxLat) < 1e-5
)
}
/**
* Full pipeline: compute bbox → guard → prefetch → update syncMeta.
* Designed to be called fire-and-forget from tripSyncManager.
*
* Set `force` to prefetch even when this bbox was already covered by an earlier
* run — the "prepare for offline" path does, so the user gets a guarantee
* rather than a promise based on our own bookkeeping.
*/
export async function prefetchTilesForTrip(
tripId: number,
places: Place[],
tileUrlTemplate?: string,
awaitAll = false,
force = false,
): Promise<void> {
const template = tileUrlTemplate || DEFAULT_TILE_URL
const bbox = computeBbox(places)
if (!bbox) return
// Unchanged bbox → the tiles are already there. Skipping outright keeps a
// routine login from re-walking thousands of tile URLs just to find them all
// cached.
const existing = await offlineDb.syncMeta.get(tripId)
if (!force && existing?.tilesBbox && sameBbox(existing.tilesBbox, bbox)) return
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
// their lower zooms cached instead of being skipped entirely.
@@ -216,7 +321,7 @@ export async function prefetchTilesForTrip(
// tile providers that don't send CORS headers. To stop the browser evicting
// these tiles under the inflated quota, we request persistent storage at app
// init instead (sync/persistentStorage.ts).
const fetched = await prefetchTiles(bbox, template, 10, 16, awaitAll)
const fetched = await prefetchTiles(bbox, template, 10, 16)
// Update syncMeta with bbox and tile count
const meta = await offlineDb.syncMeta.get(tripId)
@@ -228,6 +333,6 @@ export async function prefetchTilesForTrip(
}
if (fetched > 0) {
console.info(`[tilePrefetch] trip ${tripId}: queued ${fetched} tiles for caching`)
console.info(`[tilePrefetch] trip ${tripId}: cached ${fetched} tiles`)
}
}
@@ -14,14 +14,18 @@ import {
countTiles,
prefetchTiles,
prefetchTilesForTrip,
clearTileCache,
MAX_TILES,
TILE_CONCURRENCY,
type TileBbox,
} from '../../../src/sync/tilePrefetcher';
import { offlineDb, clearAll, upsertSyncMeta } from '../../../src/db/offlineDb';
import { setAuthed } from '../../../src/sync/authGate';
import { buildPlace } from '../../helpers/factories';
beforeEach(async () => {
await clearAll();
setAuthed(true);
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
// Stub fetch + serviceWorker so prefetch path is exercised
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
@@ -33,6 +37,7 @@ beforeEach(async () => {
});
afterEach(() => {
setAuthed(false);
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
@@ -121,6 +126,23 @@ describe('buildTileUrl', () => {
expect(url).toMatch(/^https:\/\/[abcd]\.tiles\.example\.com\/10\/0\/0\.png$/);
});
it('picks the subdomain deterministically so a tile keeps one URL', () => {
const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png';
// A rotating counter would hand out a different host on each call, which
// both breaks the cache lookup and stores the tile four times over.
const first = buildTileUrl(tmpl, 10, 479, 329);
buildTileUrl(tmpl, 10, 480, 330);
expect(buildTileUrl(tmpl, 10, 479, 329)).toBe(first);
});
it('spreads neighbouring tiles across subdomains', () => {
const tmpl = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png';
const hosts = new Set(
[0, 1, 2, 3].map(dy => buildTileUrl(tmpl, 10, 479, 329 + dy).slice(8, 9)),
);
expect(hosts.size).toBe(4);
});
it('removes {r} (retina placeholder)', () => {
const tmpl = 'https://tiles.example.com/{z}/{x}/{y}{r}.png';
const url = buildTileUrl(tmpl, 10, 0, 0);
@@ -185,6 +207,102 @@ describe('prefetchTiles — normal operation', () => {
});
});
// ── throttling ────────────────────────────────────────────────────────────────
describe('prefetchTiles — throttling', () => {
/** fetch stub that resolves on the next macrotask and tracks concurrency. */
function trackingFetch() {
const state = { inFlight: 0, peak: 0, calls: 0 };
vi.stubGlobal('fetch', vi.fn(() => {
state.calls++;
state.inFlight++;
state.peak = Math.max(state.peak, state.inFlight);
return new Promise(resolve => setTimeout(() => {
state.inFlight--;
resolve({ ok: true });
}, 0));
}));
return state;
}
it('never exceeds TILE_CONCURRENCY requests in flight', async () => {
const state = trackingFetch();
const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 };
await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 12);
expect(state.calls).toBeGreaterThan(TILE_CONCURRENCY);
expect(state.peak).toBeLessThanOrEqual(TILE_CONCURRENCY);
});
it('resolves only once every tile has been dealt with', async () => {
const state = trackingFetch();
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const fetched = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11);
expect(fetched).toBe(state.calls);
expect(state.inFlight).toBe(0);
});
it('stops fetching when the user logs out mid-run', async () => {
let calls = 0;
vi.stubGlobal('fetch', vi.fn(() => {
if (++calls === TILE_CONCURRENCY) setAuthed(false);
return Promise.resolve({ ok: true });
}));
const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 };
await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 12);
// The workers finish their current tile, then bail — nowhere near the ~336
// tiles this bbox enumerates.
expect(calls).toBeLessThan(2 * TILE_CONCURRENCY);
});
it('goes quiet when the connection drops mid-run', async () => {
let calls = 0;
vi.stubGlobal('fetch', vi.fn(() => {
if (++calls === TILE_CONCURRENCY) {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
}
return Promise.resolve({ ok: true });
}));
const bbox: TileBbox = { minLat: 48.0, maxLat: 49.0, minLng: 2.0, maxLng: 3.0 };
await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 12);
expect(calls).toBeLessThan(2 * TILE_CONCURRENCY);
});
});
// ── cache reuse ───────────────────────────────────────────────────────────────
describe('prefetchTiles — cache reuse', () => {
it('skips tiles already in Cache Storage instead of hitting the network', async () => {
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue({}) }),
});
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const fetched = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11);
expect(fetched).toBe(0);
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it('still fetches when Cache Storage has no entry for the tile', async () => {
vi.stubGlobal('caches', {
open: vi.fn().mockResolvedValue({ match: vi.fn().mockResolvedValue(undefined) }),
});
const bbox: TileBbox = { minLat: 48.8, maxLat: 48.9, minLng: 2.3, maxLng: 2.4 };
const fetched = await prefetchTiles(bbox, 'https://{s}.example.com/{z}/{x}/{y}.png', 10, 11);
expect(fetched).toBeGreaterThan(0);
});
});
// ── prefetchTilesForTrip ──────────────────────────────────────────────────────
describe('prefetchTilesForTrip', () => {
@@ -239,6 +357,62 @@ describe('prefetchTilesForTrip', () => {
});
});
// ── repeat runs ───────────────────────────────────────────────────────────────
describe('prefetchTilesForTrip — repeat runs', () => {
const places = [buildPlace({ trip_id: 1, lat: 48.8566, lng: 2.3522 })];
const tmpl = 'https://{s}.example.com/{z}/{x}/{y}.png';
beforeEach(async () => {
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
});
it('does nothing on a second run with an unchanged bbox', async () => {
await prefetchTilesForTrip(1, places, tmpl);
const first = vi.mocked(fetch).mock.calls.length;
expect(first).toBeGreaterThan(0);
vi.mocked(fetch).mockClear();
await prefetchTilesForTrip(1, places, tmpl);
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
});
it('prefetches again once a place moves the bbox', async () => {
await prefetchTilesForTrip(1, places, tmpl);
vi.mocked(fetch).mockClear();
const moved = [...places, buildPlace({ trip_id: 1, lat: 45.4642, lng: 9.19 })];
await prefetchTilesForTrip(1, moved, tmpl);
expect(vi.mocked(fetch)).toHaveBeenCalled();
});
it('runs anyway when forced (prepare for offline)', async () => {
await prefetchTilesForTrip(1, places, tmpl);
vi.mocked(fetch).mockClear();
await prefetchTilesForTrip(1, places, tmpl, true);
expect(vi.mocked(fetch)).toHaveBeenCalled();
});
it('clearTileCache resets the bboxes so the next sync refills the cache', async () => {
await prefetchTilesForTrip(1, places, tmpl);
vi.mocked(fetch).mockClear();
vi.stubGlobal('caches', { delete: vi.fn().mockResolvedValue(true) });
await clearTileCache();
expect((await offlineDb.syncMeta.get(1))!.tilesBbox).toBeNull();
vi.unstubAllGlobals();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
await prefetchTilesForTrip(1, places, tmpl);
expect(vi.mocked(fetch)).toHaveBeenCalled();
});
});
// ── cap coherence ───────────────────────────────────────────────────────────────
describe('MAX_TILES budget', () => {