The progress bar hits 87%. The client retries after a flaky cafe Wi-Fi drop. The bar jumps to 91%, then snaps back to 64%. Support files a ticket: "upload finished but playback is still spinning." Ops digs in and finds three different truths fighting each other — bytes the client thinks it sent, bytes the server accepted, and a transcode worker still encoding a prefix the client already replaced.
Resumable upload protocols exist so that number can be honest. Most production bugs I see are not exotic crypto; they are five shortcuts that make the percentage lie.
This post is a checklist: idea, why it hurts, skeleton of the fix. Snippets are truncated on purpose. The sample is a teaching-scale stand-in for a YouTube-style Hydra pipeline (channel workers + a fake encoder), not a clone of Google's fleet.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, working, tested project behind this post (
youtube-resumable-upload-dotnet) is inside — full source, protocol tests, and an in-depth PDF. Join Tech Skill Builder
Mistake 1: Trusting the client's percent instead of the server's Range
Clients love to compute uploaded / total from local counters. After a retry that overlapped, that counter is fiction. The only durable progress is what the server last accepted.
Protocol shape that keeps you honest:
-
POSTinitiate →Locationwith an upload id. -
PUTwithContent-Range: bytes START-END/TOTAL. - Status query:
Content-Range: bytes */TOTAL→ 308 Resume Incomplete plus aRangeheader likebytes=0-{accepted-1}. - Final chunk lands → 201 with the video resource.
// Status query path — never invent progress from the request body. if (range.IsStatusQuery) { var rangeHeader = ContentRangeParser.FormatRangeHeader(session.BytesReceived); // 308 Resume Incomplete + Range: bytes=0-{BytesReceived-1} return new UploadOutcome.ResumeIncomplete(session, rangeHeader); // full ResumableUploadService in the complete project } Wire the UI to the last Range from a status query (or from the 308 after a partial PUT). If the cafe Wi-Fi drops mid-chunk, ask bytes */TOTAL before guessing.
Mistake 2: Returning 400 when FirstByte is wrong
Google-style resumable uploads are contiguous. The next chunk's first byte must equal BytesReceived. Overlapping lower or skipping higher uploads nothing.
The mistake: treat a wrong FirstByte as a client bug and answer 400 Bad Request. Clients then stop, or worse, open a brand-new session and double-bill the user.
The right answer is the same 308 you use for a status query — tell them where you really are:
// Contiguous only. Wrong FirstByte → 308, not 400. if (range.FirstByte != session.BytesReceived) { var rangeHeader = ContentRangeParser.FormatRangeHeader(session.BytesReceived); return new UploadOutcome.ResumeIncomplete(session, rangeHeader); // full HandlePutAsync branch in the complete project } 400 is for malformed headers and impossible totals. Misaligned offsets are a resume signal.
Mistake 3: Shipping arbitrary chunk sizes (except the final short one)
Non-final chunks must be multiples of 256 KiB. The last chunk may be short. Skip that rule and you get intermittent rejects that look like "random 400s on large files" — usually when the client's buffer size drifted off the multiple.
public static bool IsValidChunkSize(long chunkLength, long firstByte, long totalLength) { var isFinal = firstByte + chunkLength >= totalLength; if (isFinal) return chunkLength > 0 && firstByte + chunkLength == totalLength; return chunkLength > 0 && chunkLength % (256 * 1024) == 0; // full ContentRangeParser in the complete project } Document the multiple in your OpenAPI notes. Unit-test a 256 KiB-aligned middle chunk, a short final, and a 200 KiB middle that must fail.
Mistake 4: Collapsing three lifecycle events into one "done"
Upload finished is not playback ready. Early-transcode (the Hydra-shaped idea: start encoding before every byte lands) makes that worse if you only have one boolean.
Keep at least three signals separate:
| Event | Meaning |
|---|---|
UploadCompleted | All bytes durable, video id assigned (201) |
BaseQualityReady | A playable low ladder rung exists for the current generation |
LadderComplete | Remaining rungs finished |
// Do not collapse these into session.Status == Completed. public sealed record TranscodeProgress( string JobId, string VideoId, TranscodeJobStatus Status, // Queued | Processing | BaseReady | Complete | Failed int CompletedChunks, int ChunkCount, bool BaseQualityPublished, long ContentGeneration); // full TranscodeQueue + FakeHydraEncoder (teaching-scale) in the complete project Your UI can show "uploaded" from UploadCompleted, unlock a scrubber from BaseQualityReady, and hide the "HD still processing" chip only on LadderComplete. One green checkmark for all three is how you get "it says done but 720p is black."
Mistake 5: Early-transcode without a generation fence on rewind
This is the quiet production killer. You enqueue early work when BytesReceived crosses a threshold. A client then discovers corruption (or your own retry path) and rewinds the offset. Old workers still hold messages for the superseded prefix. If they publish base quality anyway, viewers get a Frankenstein of old and new bytes.
What has to happen on rewind:
- Bump
ContentGeneration. - Abort the old generation's in-flight jobs.
- Zero the superseded blob suffix (
FileBlobStore.TruncateAsyncstyle) so fingerprints change. -
TryPublishBaseReadymust refuse aborted generations.
public long BumpOnOffsetRewind(UploadSession session, long newOffset) { if (newOffset >= session.BytesReceived) return session.ContentGeneration; AbortGeneration(session.UploadId, session.ContentGeneration); session.ContentGeneration += 1; return session.ContentGeneration; // full ContentGenerationFence + TruncateAndRewindAsync in the complete project } public bool TryPublishBaseReady(string uploadId, long generation) { // refuse if aborted, or if a newer/equal gen already published // full fence gate in the complete project } Without that fence, your progress bar can honestly say 100% uploaded while the player serves a base rung encoded from bytes you already threw away.
Ship checklist (short)
- Progress UI reads server
Range/ status-query results, not local counters. - Wrong
FirstByte→ 308 Resume Incomplete +Range, never a hard 400. - Non-final chunks are 256 KiB multiples; final short chunk is allowed; complete → 201.
- Emit
UploadCompleted,BaseQualityReady, andLadderCompleteas separate facts. - On offset rewind: bump generation, abort old work, zero superseded suffix, refuse stale
TryPublishBaseReady.
None of that is glamorous. The glamorous part is a smooth progress bar on demo day. The durable part is a percentage that still matches BytesReceived after the third cafe Wi-Fi drop — and a base-quality publish that belongs to the generation still on disk.
When a client rewinds from 40 MB back to 8 MB while an early-transcode worker is mid-encode on generation 1, what should TryPublishBaseReady do — publish the old rung, wait silently, or refuse and make generation 2 earn base quality again?
The full fence, blob truncate, channel workers, and protocol suite live in the complete project.
Get the complete project
ZIP + in-depth PDF live in Tech Skill Builder: full .NET 10 solution (youtube-resumable-upload-dotnet), ContentRangeParser, ResumableUploadService, ContentGenerationFence, FileBlobStore truncate/rewind, teaching-scale TranscodeQueue + FakeHydraEncoder, and the scenarios this post deliberately leaves closed.
Tech Skill Builder is only $5/month for a very limited time (normally $10). Grab it now before it goes back to $10.
Join Tech Skill Builder