Skip to content

Blob Upload Stuck

  • Your .mutate("attach", { attachment: file }) returned successfully.
  • The row shows the FileRef in useLiveQuery.
  • 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.

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.

Open the Devtools panel or read the outbox directly:

// In devtools console:
const idb = await plasma.__internal.idb // via useDevtoolsSnapshot's schema
const 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.

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.

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.

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.

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.

Common reasons:

  • Wrong bucket configured on the Worker. Check wrangler.jsonc binding matches the bucket you actually created.
  • maxSize limit exceeded. The column declared file({ maxSize: 5 * 1024 * 1024 }) and the user picked a 20MB image. Client-side, desugarFileArgs should catch this — if it didn’t, the server-side PUT rejects.
  • mimeAllowList restriction. Column declared file({ 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:handleBlobPut threw. Check wrangler tail for the actual error.