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
lib/uploads.ts
multipart: true on the handler or a route uses parts at every 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
app/api/cron/sweep-uploads/route.ts
vercel.json
abortStaleMultipartUploads lists the bucket’s incomplete uploads, aborts every one started longer ago than olderThan, and returns what it aborted. Pick an olderThan 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.lib/uploads.ts
1
Insert a pending row in onBeforeUpload
It runs once per upload, before anything is signed, and already knows the path.
2
Flip it to ready as the last thing onUploadComplete does
Do all other work first. If the flip came earlier, a crash after it would leave a ready row whose work never happened.
3
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.
uploadId first reaches your code in onUploadComplete, so the example mints its own row id in onBeforeUpload and passes it as state, which reaches onUploadComplete typed.The cron
Add the row sweep to the route above. The abort runs first, and both halves share one window.app/api/cron/sweep-uploads/route.ts
- One path, one upload. The sweep deletes whatever stands at a pending row’s path, so a path must never be shared.
uniquePathgives 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: truestill 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
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
onUploadCompletethrow on a database error. Any throw deletes the object, and the browser’s retriedendthen 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.uniquePathis the fix; see Writing. - 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.