Blob Upload Stuck
Symptom
Section titled “Symptom”- Your
.mutate("attach", { attachment: file })returned successfully. - The row shows the
FileRefinuseLiveQuery. - The outbox has a pending entry.
- But the mutation never pushes to the server. The Devtools panel
shows
outboxDepth: 1+and it doesn’t drain.
The contiguous-prefix push rule
Section titled “The contiguous-prefix push rule”plasma’s push loop guarantees contiguous prefix delivery:
mutation N is not sent to the server until every mutation < N has
either been confirmed OR (if it has blob deps) had its blobs
uploaded.
If mutation 1 has a blob-upload-failed, mutation 2 sits in the
outbox forever — even if mutation 2 itself has no blob deps.
Diagnose
Section titled “Diagnose”Open the Devtools panel or read the outbox directly:
// In devtools console:const idb = await plasma.__internal.idb // via useDevtoolsSnapshot's schemaconst tx = idb.transaction("_plasma_blob_uploads", "readonly")const uploads = await tx.objectStore("_plasma_blob_uploads").getAll()console.log(uploads)Each upload record has a state:
"queued"— waiting to run"uploading"— currently being PUT"present"— successfully uploaded (record cleaned up shortly)"failed"— retries exhausted, needs your attention
If any upload is "failed", that’s your culprit.
Fix 1 — retry
Section titled “Fix 1 — retry”If the failure was transient (network flake, R2 briefly unhappy):
await client.retryBlobUpload(mutationID)The upload record moves back to queued with a fresh attempt
budget.
Fix 2 — discard
Section titled “Fix 2 — discard”If the mutation is unrecoverable (user closed the tab, decided they don’t want that attachment after all):
await client.discardMutation(mutationID)Drops the outbox entry AND the upload record. The optimistic view
reverts on the next rebuildOptimistic.
Fix 3 — hook it in your UI
Section titled “Fix 3 — hook it in your UI”Wire onError to expose the mutation ID:
createPlasmaClient({ ..., onError: (err) => { if (err.kind === "blob-upload-failed") { setStuck((prev) => [...prev, err]) // { kind, mutationID, hash, cause } } },})Render each stuck upload with retry/discard buttons.
Why not just skip and continue?
Section titled “Why not just skip and continue?”The push order is preserved for a reason: mutation 2 might depend
on mutation 1’s effect. If 1 inserts a note and 2 attaches an
image to that note, delivering 2 first would fail
auth.write (note doesn’t exist yet).
The contiguous-prefix rule keeps the server’s view consistent with the client’s mutation timeline. Blob-block-until-uploaded is a consequence.
Why did the upload fail?
Section titled “Why did the upload fail?”Common reasons:
- Wrong bucket configured on the Worker. Check
wrangler.jsoncbinding matches the bucket you actually created. maxSizelimit exceeded. The column declaredfile({ maxSize: 5 * 1024 * 1024 })and the user picked a 20MB image. Client-side,desugarFileArgsshould catch this — if it didn’t, the server-sidePUTrejects.mimeAllowListrestriction. Column declaredfile({ mimeAllowList: ["image/*"] })and the user picked a PDF.- R2 rate limits. Rare, but if you’re uploading fast and hitting R2 quotas, retries will eventually resolve.
- Server 500. Something in
sync-handler.ts:handleBlobPutthrew. Checkwrangler tailfor the actual error.
What to read next
Section titled “What to read next”- Files and blobs — the full upload state machine
- Sync errors —
blob-upload-failedin the error kind table