Skip to content

Vercel Blob Upload Issues since 3.90.X #18362

Open

Description

@goshawkup

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

  1. 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
  2. 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.

  3. For actual Admin uploads, set BLOB_READ_WRITE_TOKEN to a disposable test store's token in an ignored root .env, together with PAYLOAD_DATABASE=sqlite and SQLITE_URL=file:./community-repro.db. Run PAYLOAD_DATABASE=sqlite pnpm dev _community, open /admin, and log in with the upstream test credentials dev@payloadcms.com / test.

  4. Timeout: create and save separate media documents with test/_community/fixtures/below.png, at.png, and above.png (one byte below, exactly at, and one byte above 1 MiB). Observe the initial Blob upload separately from finalisation via POST /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.

  5. WebP: create and save separate documents using static.webp and animated.webp; upload blue.jpg and small.png as controls. Download each document's final URL. Check for non-empty, decodable bytes, 64×64 dimensions, and two frames for the animated WebP.

  6. 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.

  7. 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

  1. added
    status: needs-triagePossible bug which hasn't been reproduced yet
    validate-reproductionAuto-added tag on create to tell bot to check recreation URL, removed after check.
    on Sep 28, 2026
  2. added and removed
    validate-reproductionAuto-added tag on create to tell bot to check recreation URL, removed after check.
    on Sep 28, 2026
  3. PP-Tom commented on Sep 28, 2026

    @PP-Tom

    Number 1; I fixed by replacing await with void (

    await reader.cancel().catch(() => undefined)
    ) using a pnpm patch

  4. erik-ff commented on Sep 30, 2026

    @erik-ff

    @PP-Tom this is how we fixed it as well.

    diff --git a/dist/uploads/getFileFromClientUpload.js b/dist/uploads/getFileFromClientUpload.js
    index f42abe7c5d2052041528bbc246f379048f2b8b16..e287936514a02e9dbbe363c9e1a0121c23c88939 100644
    --- a/dist/uploads/getFileFromClientUpload.js
    +++ b/dist/uploads/getFileFromClientUpload.js
    @@ -151,7 +151,10 @@ export async function getFileFromClientUpload({ file, req }) {
                 bytesRead += chunk.length;
             }
         } finally{
    -        await reader.cancel().catch(()=>undefined);
    +        // payload#18362: awaiting reader.cancel() // doesn't always resolve
    +        void reader.cancel().catch(()=>undefined);
         }
         const probedFile = {
             name: file.filename,
  5. drianoaz commented on Oct 19, 2026

    @drianoaz

    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-blob 3.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/media with no response (the browser's PUT to 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 mirrors fetchHeaderOnly + the Vercel Blob adapter's getFile:

    Read pattern Settled Still pending after 15 s
    Range 1 MiB, new Response(upstream.body) wrapper (what getFile returns), read 1 MiB, await reader.cancel() 0 20
    Same, but cancelling the unwrapped fetch body resolved every time 0
    Full body, same wrapper, streamed to a file with pipeline (no cancel) 20 0
    The wrapped + cancel case in plain Node (no Next) resolved every time 0

    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 done without cancelling.

    Route used for the isolated reproduction
    export async function probe(url) {
      const LIMIT = 1024 * 1024
      const upstream = await fetch(`${url}?${Date.now()}`, { headers: { Range: `bytes=0-${LIMIT - 1}` } })
      const reader = new Response(upstream.body, { status: 206 }).body.getReader()
      let read = 0
      while (read < LIMIT) {
        const { done, value } = await reader.read()
        if (done || !value) break
        read += value.length
      }
      return Promise.race([
        reader.cancel().then(() => 'resolved'),
        new Promise((r) => setTimeout(() => r('still pending after 15s'), 15000)),
      ])
    }

    Workaround without patching node_modules: setting upload.mimeTypes (we use ['image/*', 'video/mp4']) makes getFileContentRequirement return '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. The void reader.cancel() patch from the comments above matches what we observed.

  6. akshatshahh commented on Oct 19, 2026

    @akshatshahh

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions