storage-s3: only the first upload in a process is stored (Local API, Cloudflare R2)
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 48/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- aws, node.js, typescript
Research direction
Run the supplied upload-many.ts reproduction with s3Storage configured for Cloudflare R2, then inspect handleUpload and uploadFile in @payloadcms/storage-s3 while comparing the adapter calls with the direct S3 client. Done means sequential payload.create uploads store every object, including generated image sizes, and later documents do not resolve with missing files.
Written by the indexing model from the issue text.
Description
Describe the Bug
When several media documents are created in one Node process through the Local API, with @payloadcms/storage-s3 pointed at Cloudflare R2, only the first upload results in an object in the bucket.
Every later payload.create({ collection: 'media', file }) resolves normally: the document is created, doc.url is populated, no error is thrown and the process exits 0. The object simply is not in the bucket.
What I ruled out
| Varied | Result |
|---|---|
| File size (2 KB avatar → 40 KB PNG) | one per process |
| Order (the "first" file differs each run as earlier ones succeed) | always the first, whichever it is |
clientUploads: true / false |
no difference |
| Checking immediately vs. retrying with cache-busting for 10s | no difference — the object never appears |
Local disk storage (no s3Storage) |
all 16 files stored in a single process |
Same credentials/bucket/endpoint via @aws-sdk/client-s3 directly |
3/3 stored, each verified with HeadObject |
The direct-SDK check uses the same client options the adapter sets, so credentials, bucket, endpoint, region and the keep-alive agents are all exercised the same way:
new S3({
credentials: { accessKeyId, secretAccessKey },
endpoint: `https://${account}.r2.cloudflarestorage.com`,
forcePathStyle: true,
region: 'auto',
requestHandler: {
httpAgent: { keepAlive: true, maxSockets: 100 },
httpsAgent: { keepAlive: true, maxSockets: 100 },
},
})
Three sequential PutObjectCommand calls on one client: 3/3 stored. The same three files through payload.create: 1/3.
Note the first upload stores its generated image sizes too (name.png and name-300x300.png both land), so it isn't "one object per process" — it's one upload per process.
Reproduction
// npx payload run upload-many.ts, with s3Storage configured for R2
import config from '@payload-config'
import fs from 'fs/promises'
import { getPayload } from 'payload'
const payload = await getPayload({ config })
for (const name of ['a.png', 'b.png', 'c.png']) {
const data = await fs.readFile(`./images/${name}`)
const doc = await payload.create({
collection: 'media',
data: { alt: name },
file: { data, mimetype: 'image/png', name, size: data.length },
})
const head = await fetch(doc.url, { method: 'HEAD' })
console.log(name, doc.url, head.status) // a.png 200, b.png 404, c.png 404
}
media is a standard upload collection with imageSizes and, in my case, disablePayloadAccessControl: true plus a generateFileURL pointing at the bucket's custom domain.
Environment
payload3.89.0@payloadcms/storage-s33.89.0@payloadcms/plugin-cloud-storage3.89.0@aws-sdk/client-s33.1130.0@payloadcms/db-postgres3.89.0 (Neon, Postgres 18)- Node 24.16.0, Ubuntu (GitHub Actions runner)
NODE_ENV=production- Storage: Cloudflare R2 via the S3 API
Why it matters
uploadFile awaits client.putObject(...), so a rejected PUT would surface. Since nothing rejects, the failure is silent: you end up with documents whose files do not exist, and nothing in the logs suggests it. In my case fifteen of sixteen images were lost this way and the site served broken images for days before anyone noticed.
I have not isolated whether handleUpload is invoked at all for the later files. Happy to add instrumentation or test a patch if that would help.
Workaround
One upload per process — run the import script repeatedly until nothing is left to store, and verify each object exists rather than trusting the resolved promise.
- Dominant language
- TypeScript
- Stars
- 45.5k
- Forks
- 4.3k
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 299
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- No Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from payloadcms/payload
-
WhereBuilder throws "Cannot read properties of undefined (reading 'value')" when the list URL filters on a field that is not filterablePossibly taken A pull request linked to this issue is open or already merged. OpenBug
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
payloadcms/payload#19002 · 1 comment ·
Maintainers usually reply within 1 day
-
Regression: `findByID` with `disableErrors: true` throws `NotFound` for null/empty ids since 3.91.0Possibly taken @Krrishkumar125 claimed this 4 days ago. Openarea: core Bug invalid-reproduction status: needs-triage v3
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
payloadcms/payload#18935 · 1 comment ·
Maintainers usually reply within 1 day
-
bug: Local API call with req resets req.fallbackLocale to defaultLocalePossibly taken A pull request linked to this issue is open or already merged. Openarea: core Bug created-by: Contributor
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
payloadcms/payload#18768 ·
Maintainers usually reply within 1 day
-
[plugin-multi-tenant] Infinite redirect loop with custom tenantFieldName and isGlobal collectionsPossibly taken A pull request linked to this issue is open or already merged. OpenBug plugin: multi-tenant status: needs-triage v3
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
payloadcms/payload#18690 ·
Maintainers usually reply within 1 day
-
Global update drops overrideAccess, so Local API updateGlobal fails filterOptions validation (3.90 regression)Possibly taken @PINYOPATTANAWASANPORN claimed this 12 days ago. Openarea: core Bug
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
payloadcms/payload#18674 · 2 comments · 1 reaction ·
Maintainers usually reply within 1 day
All issues in payloadcms/payload
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
openremote/openremote#3487 · 1 comment ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
coollabsio/jean#867 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
ehmpathy/rhachet-roles-bhuild#616 ·
Maintainers usually reply within 1 day
-
📘 Docs supabase/cli
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
Maintainers usually reply within 1 day
-
bug(cli): theme file home-directory check uses startsWith (sibling dirs pass, Windows casing fails)Openarea/core kind/bug priority/p2 status/bot-triaged
Difficulty 2/5 1-3 hours Newbie friendliness 79/100
google-gemini/gemini-cli#29955 · 2 comments ·
Maintainers usually reply within 1 day