Mobile Computing · Mobile Storage
Storage Pressure: What a Device Does When the Disk Fills
How the platform reclaims space from caches under pressure, why a nearly full flash volume slows down before it fails, and how an app should budget and shed data before the system does it.
A full disk on a phone does not behave like a full disk on a server. The server fails a write with a clean error and the operator adds capacity. The phone slows down first, because a flash device with little free space has few erased blocks to program into and must erase on the critical path of every write; then the platform starts deleting things the app did not ask it to delete; then the write fails. The application that treats ENOSPC as the first symptom has already lost data by the time it sees it.
The sequence is a consequence of how flash and the filesystem above it interact, and it is predictable enough to design for.
Free space is a write-amplification parameter
A flash device can program a page but cannot overwrite one; it must erase a whole block, which is a few hundred kilobytes, before any page in it can be rewritten. The controller maintains a pool of erased blocks and moves live pages out of blocks it wants to erase. That pool is the device’s working capital.
When the host filesystem is nearly full, the controller has less freedom to choose which blocks to erase, because more of the device holds live data. It must copy live pages out of a block to reclaim it, and those copies consume both bandwidth and additional free blocks. The result is write amplification: one host write can cost several device-level page programs and an erase. The device’s own over-provisioning (the difference between its raw capacity and the capacity it advertises) is what absorbs this, and it is typically a modest percentage rather than a large reserve.
The observable effect is that a device with a few percent free space has significantly lower write throughput and higher write latency than the same device with a third free. The magnitude is device-specific and not something to state as a number, but the direction is universal and it is why a phone that “has space” can feel slow.
The filesystem adds a second layer. A copy-on-write filesystem that writes new blocks before releasing old ones needs free space to perform a write at all, so a nearly full volume can fail a write that would fit if it were done in place. A log-structured filesystem has the same property. An application that saves by writing a temporary and renaming (the sequence described in atomic file replacement) needs twice the file’s size free at the moment of the save, not once.
What the platform evicts and in what order
Both mobile platforms treat caches as the first thing to reclaim, and both define cache as a location rather than a property of the data.
Android’s cache directory is subject to deletion when the device is low on space, and the system may clear it without notifying the app. iOS marks the caches directory as purgeable and the system removes its contents under pressure, again without notification. In both cases the app is expected to discover the loss on the next access rather than to be told.
The order beyond caches differs. Android’s storage settings let the user clear an app’s data entirely, and the system may offload or remove unused apps when space is critically low. iOS’s offloading mechanism removes an app’s executable while preserving its documents and data, so a reinstall restores the user’s content without the binary. Neither mechanism asks the app’s opinion, and both are why the durable-versus-cache split described in app container layout is a correctness requirement rather than a tidiness preference.
The asymmetry that catches applications is that the platform’s definition of expendable is broader than the app’s. A downloaded video that the user explicitly chose to keep offline is durable from the user’s point of view and is stored in a directory the app considers durable, but if that directory is inside the cache subtree it is expendable from the platform’s. The fix is to place it in a durable directory and mark it excluded from backup, which costs the user’s backup quota nothing and keeps it out of the platform’s reclamation.
Budgeting against a quota rather than a disk
An application that measures its own storage against the device’s free space is measuring the wrong quantity. The device’s free space is shared with every other app and with the user’s photos, and it can change by gigabytes between two launches.
A quota is the workable abstraction: a number the app decides, stored in its own configuration, that its durable plus cache footprint should not exceed. The number is a policy choice, and the useful policies are expressed as a fraction of something the app can observe: the container’s total size, the device’s total capacity, or a fixed cap that the app’s data model makes reasonable.
Against that quota, the app needs a ranked eviction order. Caches go first, oldest-accessed first, because they are rebuildable. Then derived data that is expensive but reproducible. Then user content, in an order the user can understand, and at that point the app must ask rather than decide.
The ranking is worth making explicit in the schema, because a ranking expressed only in code is one that a later feature will not respect. A single last_used column and a class column on the cache table is enough for the first two tiers, and the query that selects victims becomes a single ordered scan rather than a per-feature judgement.
Detecting pressure before the write fails
Both platforms expose a signal that the device is short of space, and both expect the app to respond by releasing what it can. The signal arrives before writes fail, which makes it the only opportunity to shed data gracefully.
The response should be bounded in time and in work. Deleting ten thousand cache entries individually is ten thousand directory operations, each of which is a metadata write; the platform may kill the app for taking too long in the callback. Deleting a whole cache directory in one operation is one tree removal and is what the callback is designed for.
The second signal is the write failure itself, and its handling is where most implementations are wrong. A failed write leaves a partially written temporary file, and the natural response (retry after freeing something) is often impossible because the freeing operation also needs to write. The correct sequence is to close and delete the temporary, free space from a location that requires only metadata operations, and retry once. A retry loop that does not free anything converts a recoverable condition into a hang.
-- Cap the database's growth so it fails predictably, not with the disk.
PRAGMA max_page_count = 51200; -- 51200 pages; at 4096 bytes, about 200 MB
PRAGMA page_count; -- compare against the cap before bulk writes
max_page_count makes the database return a disk-full error at a size the app chose, which is far easier to handle than the filesystem’s error arriving in the middle of a b-tree split. The app can then evict from its own tables, in its own order, and retry; that is a decision it is in a position to make and the filesystem is not.
What happens to the log and the journal
The write-ahead log is the component most exposed to pressure, because its size is governed by readers rather than by the database’s content. A checkpoint that cannot advance leaves the log growing, and the log lives in the same container as the database. An app that is near its quota and then starts a long read transaction can cross from comfortable to failing without any change in what it is writing.
The failure is worse than a plain disk-full, because the database and its log are a unit. A checkpoint that fails partway leaves frames in the log that have already been copied and frames that have not, and the log’s header records how far the checkpoint got. Recovery handles this correctly, but the app must not delete the log to free space: doing so discards committed transactions that were never checkpointed, which is data loss presented as housekeeping.
The safe reduction is to run a truncating checkpoint after closing readers, which copies what it can and then releases the log file’s space. If that cannot complete, the app should reduce its own durable footprint instead.
Flash lifetime as a budget line
Every write consumes a program/erase cycle from the block it lands in, and the device’s endurance is finite and specified. A database that rewrites a page per user action, with a flush per commit and a checkpoint that copies frames repeatedly, writes far more to the medium than the logical data volume suggests.
The multiplication is easy to underestimate. A 4 KiB page rewritten a thousand times is 4 MB of logical writes; with a checkpoint that copies it each time and a controller that erases a 512 KiB block to reclaim it, the device-level traffic can be an order of magnitude higher. On a device with a fixed endurance budget, an app that writes in a tight loop consumes the device’s life rather than its space.
The mitigations are the same ones that reduce latency, which is convenient: batch transactions so there are fewer barriers, checkpoint deliberately rather than per commit, and keep hot small updates in memory with periodic persistence rather than committing each one. The last is a durability trade that must be stated in the app’s own terms (how much work can be lost) rather than left implicit in a synchronous setting.
The end state is a design decision
An app that has correctly classified its files, capped its database, ranked its evictions, and handled the pressure signal will still encounter a device with no space. What it does then is a product decision expressed as a storage decision: it can refuse to accept new data and tell the user, it can discard the oldest user content without asking, or it can degrade to a mode that keeps only what fits.
The one option that is not viable is to keep writing and let the platform decide. The platform’s decision is to remove the app’s caches, then the app itself, and the user experiences that as data loss with no explanation. Stating the limit in the app’s own interface (how much is stored, what will be removed when it fills) converts an opaque failure into a rule the user can act on, and it is the only part of the storage budget the app can actually control.
References
- Android Developers — App-specific storage — Google
- Apple Developer Documentation — File System Basics — Apple Inc.
- N. Agrawal, V. Prabhakaran, T. Wobber, J. D. Davis, M. Manasse, R. Panigrahy — "Design Tradeoffs for SSD Performance" — USENIX ATC 2008
- SQLite Documentation — PRAGMA Statements — SQLite project