A user picks a file, the upload starts, and the tab closes halfway through. No callback runs and your server never hears about it again. This page covers what that leaves in the bucket and how to clean it up.
What is left depends on which side of the multipart threshold the file was on:
| Under the threshold, one PUT | Over the threshold, multipart | |
|---|---|---|
| When the object exists | the moment the last byte lands | when end completes the upload |
| What a dead tab leaves | a whole stored object, visible to list() | incomplete parts, invisible to list() |
| Who cleans it up | you, with a pending row or a list sweep | aborting stale uploads |
One cron handles both halves. The multipart half is one call. The single-PUT half needs your app’s help, because the SDK cannot tell an abandoned object from a completed one by looking at the bucket. Only your own records can.
The simplest fix: always multipart#
Set multipart on, on the handler or a route, and every upload uses parts, whatever its size. Nothing is stored until your handler completes the upload, so a closed tab always leaves an incomplete multipart upload, which the cron below cleans up. The single-PUT case disappears, and small files gain pause, resume and per-part retry.
The cost is two extra round trips between your server and storage per upload, and no extra browser requests. For most apps this one option is enough.
Sweeping incomplete multipart uploads#
Aborting stale uploads lists the bucket’s incomplete uploads, aborts every one started longer ago than the older-than window, and returns what it aborted. Pick a window comfortably longer than your slowest upload, since a paused upload can be resumed long after it started. A day is a reasonable default.
On an all-multipart app this route is the whole cleanup. The calls it is built from are on Deleting.
Single PUT: the pending row#
If you keep the single-PUT path, write a row before the bytes, flip it after them, and sweep what never flipped from the same cron.
Insert a pending row in the before-upload callback
It runs once per upload, before anything is signed, and already knows the path.
Flip it to ready as the last thing the completion callback does
Do all other work first. If the flip came earlier, a crash after it would leave a ready row whose work never happened.
Sweep rows still pending from the same cron, after the abort
An indexed query on your own table, not a bucket scan. Each row names the exact path to check.
The upload id first reaches your code in the completion callback, so the example mints its own row id in the before-upload callback and passes it as state, which reaches the completion callback.
The cron#
Add the row sweep to the route above. The abort runs first, and both halves share one window.
Two things this example depends on:
- One path, one upload. The sweep deletes whatever stands at a pending row’s path, so a path must never be shared. Unique paths give you that. With a reused path, a retry that succeeded leaves a ready row and an older pending row pointing at the same object, and the sweep would delete the good file. If you must reuse paths, skip rows whose path also has a ready row.
- Abort first, one window. A route without multipart always on still uses parts for a file over the threshold, so a pending row can belong to a paused multipart upload with nothing at its path yet. Running the abort first with the same window means the parts are gone before the row is, so a late resume fails instead of completing against a row that no longer exists. A row sweep on a shorter window would delete rows out from under uploads that can still resume.
Without a pending row#
An app that will not take a database write on the upload path can list the prefix and diff it against the rows it does have. This is weaker: list carries no metadata, so it cannot tell which upload wrote an object, and it pages through every object under the prefix to find the few that do not belong. Use it only when a pending row is not an option.
Related traps#
- Do not let the completion callback throw on a database error. Any throw deletes the object, and the browser’s retried
endthen reports a 404. See Retries and throws. - Use unique paths unless overwriting is the intent. Two single-PUT uploads to the same path race, and the loser’s
endfails withnot_foundeven though its bytes landed. A unique path is the fix; see Unique paths. - An explicit
cancel()is already handled. It tells your route, which aborts the multipart upload or deletes the single-PUT object. The gap is everything that is not an explicit cancel: a crash, a closed tab, a lost network.