Repository navigation
Vercel Blob Upload Issues since 3.90.X #18362
Description
Describe the Bug
Three client-upload regressions: header cancellation hang, empty WebP replacement, and filename overwrite authorisation
This has been prepared with the help of codex, I hope that is okay
Summary
This single reproduction covers three independent failures in the Vercel Blob client-upload flow. It uses one minimal media collection (upload: true), the Vercel Blob adapter with clientUploads: true, and synthetic fixtures. No custom workaround plugins are installed.
Link to the code that reproduces this issue
https://github.com/goshawkup/payload-upload-issue-repro
Reproduction Steps
Reproduction steps
-
Check out the linked reproduction branch (publication pending), based on Payload v3.90.2. Run these commands from the repository root:
pnpm install --frozen-lockfile node test/_community/generate-fixtures.mjs pnpm test:int _community
-
Observe five failing assertions across the three scenarios and four passing controls. These focused tests require no credentials or database. To isolate a scenario, append
-t 'header-probe',-t 'WebP processed', or-t 'duplicate-filename'to the test command. -
For actual Admin uploads, set
BLOB_READ_WRITE_TOKENto a disposable test store's token in an ignored root.env, together withPAYLOAD_DATABASE=sqliteandSQLITE_URL=file:./community-repro.db. RunPAYLOAD_DATABASE=sqlite pnpm dev _community, open/admin, and log in with the upstream test credentialsdev@payloadcms.com/test. -
Timeout: create and save separate media documents with
test/_community/fixtures/below.png,at.png, andabove.png(one byte below, exactly at, and one byte above 1 MiB). Observe the initial Blob upload separately from finalisation viaPOST /api/media. Check that finalisation completes and each final URL returns a readable image. If the hang depends on the hosted runtime, repeat in a dedicated protected Vercel test deployment; local success alone does not rule it out. -
WebP: create and save separate documents using
static.webpandanimated.webp; uploadblue.jpgandsmall.pngas controls. Download each document's final URL. Check for non-empty, decodable bytes, 64×64 dimensions, and two frames for the animated WebP. -
Filename collision: upload blue and red JPEG contents as separate documents using the same requested filename, then fetch both final URLs and verify that each retains its own contents. Repeat against an existing server-uploaded/legacy object, which the supplied browser test seeds through the multipart API. Also test concurrent creation and intentional replacement separately. The automated tests assign matching filenames without modifying the fixtures.
-
To run the prepared browser checks against the running disposable server:
pnpm exec playwright install chromium REPRO_LIVE=1 REPRO_BASE_URL=http://localhost:3000 pnpm exec playwright test --config test/_community/browser.config.ts
The 11 browser checks are prepared but have not been executed against a real Blob store. The observed failures below are from the focused upstream-function tests, not a claim that live verification has been completed.
Environment and verification status
- Payload v3.90.0 (
e10b60228b92b680663110ea2af431643bfc6126) - Payload v3.90.2 (
6254c3bf561a205223849a6497d7dae824d4e81b) - v3 maintenance head inspected/tested:
9106bf8c189b584d81016188579bcfa10db1e000 - Node 24.12.0, pnpm 11.9.0, monorepo-root Sharp 0.35.4, Vitest 4.1.2
- All three refs: 5 failed and 4 passed assertions in the focused reproduction suite.
- These tests call upstream implementations with external SDK calls/database queries stubbed. Browser tests are supplied but real Blob and hosted-runtime execution remain outstanding. The deterministic cancellation test models a non-settling stream; it does not claim every Vercel runtime produces one.
Run pnpm install --frozen-lockfile, node test/_community/generate-fixtures.mjs, then pnpm test:int _community. The README documents actual Admin reproduction steps and optional live tests against a disposable store.
1. Header probe waits indefinitely for cancellation
Steps: upload valid PNGs below, at, and above the 1 MiB probe boundary. The deterministic test supplies the same bounded response shape but holds the underlying cancellation promise pending until test cleanup.
Expected: finalisation completes once the bounded header has been read; stream cleanup must not indefinitely block document creation.
Observed locally: the below-limit control finishes; at/above-limit assertions time out waiting for completion. fetchHeaderOnly awaits reader.cancel() in its finally block before returning the probed file.
Code: getFileFromClientUpload.ts at v3.90.2.
Runtime follow-up: reproduce with the real Blob streamed HTTP 206 response in a dedicated test deployment. Record status, byte count and elapsed time without exposing tokens, receipts or raw HAR files.
Application workaround (excluded from the reproduction): wrap the storage handler only for client-upload header probes requesting bytes=0-1048575 and receiving HTTP 206 with a valid Content-Length no greater than 1 MiB. Copy the bounded bytes into an owned buffer and return a memory-backed Response, preserving status and headers. Release the original reader without calling or awaiting its cancellation; Payload then cancels the memory-backed stream. Other reads remain streamed.
2. WebP processing hands an empty buffer to Blob
Steps: independently upload the generated static and two-frame animated WebP files; download/decode the final stored object. JPEG and PNG are controls.
Expected: non-empty, decodable WebP contents, 64×64 dimensions and preserved animation frames.
Observed locally: getFileFromClientUpload creates a temporary file and an empty req.file.data. generateFileData processes WebP and removes clientUploadContext, writing the result to the temporary file. getIncomingFiles forwards both the empty buffer and tempFilePath, but the Vercel Blob adapter consumes only the buffer. Its SDK put call therefore receives zero bytes with overwrite enabled. Both WebP assertions fail; JPEG/PNG controls pass.
Code: generateFileData.ts, getIncomingFiles.ts, adapter.ts, uploadFile.ts.
The test intercepts the final Blob SDK call; it does not mutate an actual remote object or run the complete database/hook lifecycle.
Application workaround (excluded from the reproduction): prepend an afterChange hook before the cloud-storage hook. For image/webp only, when tempFilePath matches req.context.payloadClientUploadTempFilePath, the client-upload marker is gone, and req.file.data is empty, read the processed temporary file into req.file.data and update req.file.size. Reject an empty temporary file rather than uploading zero bytes. This buffers the processed WebP in memory.
3. New-upload authorisation can overwrite an existing filename
Steps: create a server-uploaded/legacy photo.jpg, then start a new client-uploaded document using the same filename but different image contents. Also test fresh client uploads sequentially/concurrently and intentional replacement separately.
Expected: creating a new document preserves the existing object's bytes and returns a distinct usable object path. Intentional replacement updates the selected document and preserves its identity.
Observed locally: the actual issue-client-upload route authorises allowOverwrite: true and returns the existing photo.jpg path when its owner is update-accessible. The request has no target document ID and does not distinguish creation from intentional replacement. That path bypasses resolveSignedURLKey and its filename deduplication. The legacy overwrite assertion fails. Concurrent authorisations when no file exists get distinct object keys and pass the control assertion.
Code: getClientUploadRoute.ts, authorizeFileOverwrite.ts.
Existing report: #17823 describes the resulting overwrite/404 behaviour. #17849 was closed without merging. This section links that known issue rather than treating it as a new discovery. The supplied live tests check the complete persistence/URL behaviour, which is not claimed from the focused authorisation test alone.
Application workaround (excluded from the reproduction): wrap authenticated issue-client-upload=1 requests to the Blob upload route. Before delegating to Payload, sanitise the requested filename with getSanitizedUploadFilename, resolve an available name with getSafeFileName, and substitute that filename in the initialisation body. Payload signs and returns the resolved name before the browser uploads the bytes. This is not an atomic filename reservation; concurrency and intentional replacement still need independent validation.
Which area(s) are affected?
plugin: cloud-storage, plugin: storage-*
Environment Info
## Environment info — actual `pnpm payload info` output
Captured on 28 September 2026 from the affected application, which still uses Payload 3.90.0. The package-manager banner containing the private project name/path and unrelated npm configuration warnings are omitted; the diagnostic output below is otherwise unchanged. This describes the application environment, not the v3.90.2 reproduction checkout or a hosted Vercel runtime.
Binaries:
Node: 24.12.0
npm: 11.6.2
Yarn: N/A
pnpm: 10.25.0
Relevant Packages:
payload: 3.90.0
next: 16.3.3
@payloadcms/db-postgres: 3.90.0
@payloadcms/drizzle: 3.90.0
@payloadcms/email-nodemailer: 3.90.0
@payloadcms/graphql: 3.90.0
@payloadcms/next/utilities: 3.90.0
@payloadcms/payload-cloud: 3.90.0
@payloadcms/plugin-cloud-storage: 3.90.0
@payloadcms/plugin-search: 3.90.0
@payloadcms/plugin-seo: 3.90.0
@payloadcms/richtext-lexical: 3.90.0
@payloadcms/storage-s3: 3.90.0
@payloadcms/storage-vercel-blob: 3.90.0
@payloadcms/translations: 3.90.0
@payloadcms/ui/shared: 3.90.0
react: 19.2.6
react-dom: 19.2.6
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.5.0: Tue Apr 22 19:54:43 PDT 2025; root:xnu-11417.121.6~2/RELEASE_ARM64_T8132
Available memory (MB): 24576
Available CPU cores: 10
Activity
PP-Tom commentedon Sep 28, 2026
Number 1; I fixed by replacing await with void (
payload/packages/payload/src/uploads/getFileFromClientUpload.ts
Line 206 in 6254c3b
erik-ff commentedon Sep 30, 2026
@PP-Tom this is how we fixed it as well.
drianoaz commentedon Oct 19, 2026
We reproduced issue 1 (header-probe hang) against a real Vercel Blob store and
next start(production mode), which the description lists as still outstanding. Payload 3.91.0,@payloadcms/storage-vercel-blob3.91.0, Next 16.4.2, Node 24.21.0.In the admin: saving a client upload above 1 MiB with no crop leaves
POST /api/mediawith no response (the browser'sPUTto Blob returns 200), until Vercel's 300 s function timeout in production. Two real PNGs (1,064,630 and 1,329,461 bytes) and a 1.8 MB JPG hung on 3.91.0. It is a race: the first large upload after a server start sometimes saves, then the same file hangs on every retry, so a single successful upload proves nothing.Isolated outside Payload, in an empty Next 16.4.2 app (
next build+next start), with a route that mirrorsfetchHeaderOnly+ the Vercel Blob adapter'sgetFile:new Response(upstream.body)wrapper (whatgetFilereturns), read 1 MiB,await reader.cancel()fetchbodypipeline(no cancel)So the hang needs both the Next server runtime and the wrapped body; files up to 1 MiB never hit it because the loop ends on
donewithout cancelling.Route used for the isolated reproduction
Workaround without patching
node_modules: settingupload.mimeTypes(we use['image/*', 'video/mp4']) makesgetFileContentRequirementreturn'full', so the header probe never runs. With it, the same files saved 9/9 with identical bytes, and a 14 MB MP4 saved too. The trade-off is that other file types are rejected. Thevoid reader.cancel()patch from the comments above matches what we observed.akshatshahh commentedon Oct 19, 2026
Taking this one. I am working on fixes for all three scenarios (header-probe hang, empty WebP replacement, filename-overwrite authorisation) based on the repro test suite linked in the issue.