skip to content

An Expo app caches offline map tiles with expo-file-system and its storage keeps growing; how do you measure and clean the tile cache safely?

level: seniorimportance: should knowfreq 24%

answer

  1. OS purges only under storage pressure
  2. one Directory per region
  3. Directory.size walks the tree synchronously
  4. delete() is recursive and throws if missing
  5. clean leftover .part files too

basics

~20 s

The OS purges Paths.cache only under storage pressure, so the app enforces its own budget: one Directory per region, measured with Directory.size and removed with delete() inside try/catch, run deferred because these calls block the JavaScript thread.

solid answer

~50 s

First I confirm where the growth is: browsed tiles belong in `Paths.cache`, but the OS only purges that directory under storage pressure, so on a phone with free space it grows until the app trims it, and anything in `Paths.document` never shrinks by itself. I lay tiles out as one `Directory` per region under `Paths.cache/tiles`, so cleanup deletes whole folders instead of thousands of files. I measure with `Directory.size`, which sums every file below it, compare against a budget and `Paths.availableDiskSpace`, then call `delete()` on the least recently used regions; it is recursive and throws if the folder is already gone, so it sits in `try/catch` because the OS may have purged it first. These calls are synchronous and walk the disk, so I run the trim deferred after startup, never on the first render, and I also remove `.part` leftovers from interrupted Android downloads.

code

typescript · 33 lines
typescript
import { Directory, File, Paths } from 'expo-file-system';

const TILE_ROOT = new Directory(Paths.cache, 'tiles');
const BUDGET_BYTES = 200 * 1024 * 1024;

export function markRegionUsed(region: Directory): void {
  new File(region, '.last-used').write(String(Date.now()));
}

function lastUsed(region: Directory): number {
  const marker = new File(region, '.last-used');
  return marker.exists ? (marker.lastModified ?? 0) : 0;
}

// Call it deferred, after the first screen has rendered, never during startup.
export function trimTileCache(): void {
  if (!TILE_ROOT.exists) return;
  const regions = TILE_ROOT.list()
    .filter((e): e is Directory => e instanceof Directory)
    .map((dir) => ({ dir, bytes: dir.size ?? 0, used: lastUsed(dir) }))
    .sort((a, b) => a.used - b.used); // least recently used first

  let total = regions.reduce((sum, r) => sum + r.bytes, 0);
  for (const r of regions) {
    if (total <= BUDGET_BYTES) break;
    try {
      r.dir.delete(); // recursive
      total -= r.bytes;
    } catch {
      // already gone: the OS may have purged it first
    }
  }
}

go deeper

for a junior

Recall that the OS clears the cache only when storage is low, and that a Directory can be deleted with everything inside it.

for a middle

Explain the measuring tools, Directory.size, list(), File.size and Paths.availableDiskSpace, and that they are synchronous calls on the JavaScript thread.

for a senior

Show the full trim: region layout, a budget, last-use tracking beyond lastModified, try/catch around delete, active downloads skipped, .part leftovers removed, all deferred past startup.

for a principal

Weigh how much storage the app may claim, what the user must control explicitly, and when an index database should replace disk walks as the source of truth.

## Why the cache keeps growing Two facts combine. **`Paths.cache` is purged by the OS only when the device runs low on storage**, so on a phone with plenty of free space nothing is ever removed. And **`Paths.document` is never purged**, so tiles written there by mistake accumulate forever. A tile cache therefore needs an app-side budget; the OS purge is a last resort, not a strategy. Deciding the eviction policy itself (least recently used, per-region pinning, time to live) is a caching-design question; this answer is about the `expo-file-system` mechanics that carry it out. Common sources of growth: - tiles for every zoom level the user ever browsed, never trimmed; - regions saved for offline use, then forgotten; - **`.part` files** left by Android downloads that failed mid-transfer, since Android streams into the target file; - files written to `Paths.document` that should have been in `Paths.cache`. ## Lay out the files so cleanup is cheap Put tiles under `new Directory(Paths.cache, 'tiles', regionId)`. Then the unit of eviction is a **directory**, and one `delete()` call removes it with everything inside: `Directory.delete()` is recursive. Deleting tile files one by one costs a native call per file and leaves empty folders behind. ## Measure with the right primitives | Primitive | What it returns | Cost | |---|---|---| | `Directory.size` | total bytes of every file below the directory, or `null` if it does not exist | synchronous full walk of the subtree | | `Directory.list()` | direct children as `File` and `Directory` objects | synchronous, one level | | `File.size`, `File.lastModified` | bytes and last write time in milliseconds | synchronous, per file | | `Paths.availableDiskSpace` | free bytes on internal storage | synchronous, cheap | All of these are **synchronous native calls that run on the JavaScript thread**. `Directory.size` over tens of thousands of tiles walks the whole tree while the JavaScript thread waits. So: 1. never trim during startup or the first render; 2. measure per region, not per tile; 3. better still, record each region's byte count and last-use time in the app's own index when it is downloaded or opened, and read the disk only to reconcile. ## A safe trim procedure 1. **Pick a budget**, for example a fixed cap or a fraction of `Paths.availableDiskSpace`. 2. **List the regions** with `list()` and keep the `Directory` entries. 3. **Order them by last use.** `lastModified` records the last write, not the last read, so track reads yourself, for example by rewriting a small `.last-used` marker file when the region is opened. 4. **Delete the oldest regions until under budget**, each `delete()` inside `try/catch`: it throws when the folder no longer exists, which happens when the OS purged it first. 5. **Skip regions with an active download**, or cancel the download first through its `AbortSignal`; deleting a folder being written into leaves an inconsistent state. 6. **Remove stale `.part` files** in the offline-region folder. 7. **Update the index** so the UI stops offering deleted regions. ## Mistakes that make it worse - relying on the OS purge and never trimming; - storing browsed tiles in `Paths.document`, where no purge ever happens; - running the full measurement on every launch; - treating `lastModified` as a read timestamp; - calling `delete()` without handling the missing-folder error.

  • Why not just rely on the OS to clear Paths.cache?
    Because the OS purges only under storage pressure. On a device with free space the cache is never cleared, so the app shows up in the system storage settings as using gigabytes. The OS purge is a safety net; the app needs its own budget.
  • How do you avoid deleting a region while it is still downloading?
    Keep a registry of active downloads keyed by region, skip those regions in the trim, or abort the download first through its `AbortController` and delete after the promise settles. Deleting a directory mid-write can leave the download failing or recreating a half-populated folder.

saying these in an interview costs you the question

  • The OS keeps Paths.cache small, so the app never needs to trim it.
  • File.lastModified tells you when a tile was last read.
  • Directory.delete() only removes empty directories.
  • Directory.size is a cheap cached value, safe to read on every launch.
  • Deleting tiles one file at a time is the efficient way to clean up.