Two symptoms, one cause.
put()throws because the blob "already exists".- You set
allowOverwrite: true, upload a new avatar, and users still see the old one.
The defaults
allowOverwriteisfalse. Writing to an existing pathname throws. This is a guard, not a bug.addRandomSuffixisfalse. Your pathname is used as given. Withtrue,avatar.jpgbecomes something likeavatar-oYnXSVczoLa9yBYMFJOSNdaiiervF5.jpg.cacheControlMaxAgeis one month. The minimum is 60 seconds.
Why overwrites look broken
Every blob, public or private, is cached on Vercel's CDN. When you overwrite or delete one:
- The CDN can take up to 60 seconds to drop the old copy.
- Browsers keep their own copy for
cacheControlMaxAge. The CDN updating does not reach a browser that already has the file.
So the new avatar exists, and the user still sees the old one.
The fix: never overwrite
Treat blobs as immutable. New content, new pathname.
const key = `${userId}/avatars/${crypto.randomUUID()}-${name}`;
await put(key, file, { access: "private", allowOverwrite: false });
await db.update(users).set({ avatarKey: key }).where(eq(users.id, userId));
await del(previousKey); // then clean up
The URL changes, so no cache anywhere can serve the old bytes. The long default cache becomes a feature: repeat views are fast and cheap.
addRandomSuffix or your own uuid?
Either gives unique pathnames. Pick one:
- Your own uuid when you need to know the pathname before the upload finishes, for example to validate it on the server first. With client uploads, the token is bound to the pathname the client names, so a server that wants to check it must mint it.
addRandomSuffix: truewhen you do not care what the pathname is. Read the final one from theput()orupload()result, never assume it.
Do not use both. A suffix on top of a uuid only makes keys longer.
When overwriting is right
A single JSON file refreshed on a schedule, where the URL must stay the same. Then:
allowOverwrite: true.- A short
cacheControlMaxAge, like 60 to 300 seconds. - For writes that must never race,
ifMatchwith the ETag you read. A mismatch throwsBlobPreconditionFailedError. - On a private store,
get(pathname, { access: "private", useCache: false })reads the latest version straight from origin. It is slower and costs Fast Origin Transfer on every call, so use it only where freshness matters.
Never allow overwrite on a client token
allowOverwrite: true in onBeforeGenerateToken means a leaked or replayed
token can replace a file that already exists. Keep it false for anything a
browser uploads.