Storage grows. Users do not. Somewhere, blobs are piling up that no row points at.
Vercel Blob bills the monthly average of your store size. An orphan costs the same as a file someone uses.
Where orphans come from
- Replaced files. A user uploads a new avatar. The row gets the new key. The old blob stays.
- Deleted rows. A message is deleted. Its attachment is not.
- Abandoned uploads. The bytes land, then the tab closes before the key is saved.
- Failed saves. The upload works, the database write throws.
Fix the first two in code
Delete the blob in the same path that drops the reference. Blob first, then row:
import { del } from "@vercel/blob";
export async function deleteMessage(id: string) {
const message = await getMessage(id);
if (message.attachmentKey) await del(message.attachmentKey);
await removeMessage(id);
}
Why that order: if the row delete fails after the blob is gone, the page shows a missing file, which you will notice. The other order leaves a blob nobody will ever find again.
del() is free (it is not billed as an operation), takes a pathname or URL,
and accepts an array. Make cleanup retry-safe: if a delete reports
BlobNotFoundError, the blob is already gone, which is the outcome you wanted.
For replacements, save the new key, then delete the old one. Not before: if the save fails you still have the old file.
Sweep the rest
Abandoned uploads and failed saves need a periodic sweep:
import { del, list } from "@vercel/blob";
const GRACE_MS = 24 * 60 * 60 * 1000;
const referenced = await loadReferencedKeys(); // every key column, first
let cursor: string | undefined;
const orphans: string[] = [];
do {
const page = await list({ cursor, limit: 1000 });
for (const blob of page.blobs) {
const old = Date.now() - blob.uploadedAt.getTime() > GRACE_MS;
if (old && !referenced.has(blob.pathname)) orphans.push(blob.pathname);
}
cursor = page.hasMore ? page.cursor : undefined;
} while (cursor);
for (let i = 0; i < orphans.length; i += 100) await del(orphans.slice(i, i + 100));
The parts that matter:
- A grace period. A blob from a minute ago with no row is a save in flight. A day is a safe floor.
- Read the references before listing. A row saved mid-run can then only protect a blob, never expose one.
- Dry run first. Print the count. If it is a large share of the store, you missed a column that holds keys.
- Mind the costs and limits. Each
list()call is one advanced operation, so uselimit: 1000. Each deleted blob counts toward the operation rate limit, even inside onedel([...])call.
Make orphans cheap to find
Put the owner first in every pathname: <userId>/<folder>/<uuid>-<name>.
Then "everything this user uploaded" is list({ prefix: "<userId>/" }), and
deleting an account's files is one loop, not a table scan.