#01The False Finish Line
A user picks a file. A bar fills up. At 100%, the interface says "Upload complete," usually with a small green checkmark that feels final. The user closes the tab, confident the thing is done.
Complete for whom?
The browser has finished sending bytes. That's genuinely true, and it's also almost the entire truth of what "100%" measures — a network transfer finishing, nothing more. It doesn't mean the file that arrived is the file the application expected. It doesn't mean the bytes are intact rather than truncated by a flaky connection. It doesn't mean anyone has checked whether the file is safe to open. It doesn't mean a human or a rule has approved it to be shown to anyone else. And if the user closed that tab a second before the request actually landed, it doesn't even mean the system knows an upload was ever attempted.
Sometimes that gap is wider than it looks from the outside — a progress bar can hit 100% while the pipeline behind it hasn't actually run yet, and there's no way to tell from the UI. That's exactly the point: the bar's job is to look finished. Whether the system has actually earned the right to trust what arrived is a separate question, and it's one a progress bar was never built to answer.
#02The Model Everyone Starts With
Every document upload starts life as roughly the same idea: the browser asks the backend for permission, the backend asks the storage provider for a short-lived, scoped Presigned URL, the browser uses that URL to send the file straight to object storage — S3, MinIO, whatever's behind it — and the storage service hands back a key. The app saves that key next to the record it belongs to, and as far as the database is concerned, there is now a file.
Browser → Upload → Storage → URL. Four steps, one round trip, done.
There is nothing wrong with this model, and it deserves more respect than the developers who eventually outgrow it tend to give it. For a huge number of real applications — internal tools, low-stakes attachments, anything where "wrong file" is an inconvenience rather than an incident — this is the correct amount of engineering. Building a five-stage trust pipeline to let someone attach a screenshot to a support ticket would be a solution in search of a problem.
It stops being enough the moment the file matters in ways the model has no way to represent. A document a customer submits and a reviewer later approves or rejects. A file that gets replaced, more than once, and needs a history. A PDF that has to be scanned before anyone downloads it, because "we let users upload arbitrary files to our servers and immediately serve them back to other users" is a sentence that should make any engineer pause. At that point the four-step model isn't wrong, exactly — it's just answering a question nobody's asking anymore. The question stopped being "did the file arrive" a while ago.
#03Two Events Wearing One Name
Here's the distinction the simple model quietly erases: an upload finishing and a file being trustworthy are not the same event. They just got compressed into the same word — "uploaded" — long enough that most developers stop noticing they're actually describing several different things at once.
Bytes arriving at storage is one event. It tells you the network worked.
Upload confirmation is a second, separate event — someone, or something, has to actually go check that what landed matches what was promised: right size, right shape, not corrupted mid-transfer.
Security scanning is a third event, running against those confirmed bytes, that asks an entirely different question — not "did this arrive correctly" but "is this safe to have on the system at all."
Approval is a fourth event, and it's the one that gets skipped most often in casual designs — a decision, made by a rule or a person, that this specific file is the one that should represent this document going forward.
Serving the file back to someone is a fifth event, downstream of all of it, and it's the one users actually experience. Everything before it is invisible if it works and catastrophic if it doesn't.
Uploaded is a storage event. Approved is a trust decision. They are not the same thing. Once that distinction is real in the system — not just in someone's head, but as something the code can check — a lot of security and data-integrity questions get easier to answer, because they collapse into one question: which of these five events has actually happened for this file, right now?
#04The Pipeline, Spelled Out
Once those are five separate events, the obvious move is to stop pretending they're one and give each of them a name the system can actually check against. Written out as a sequence, a pipeline shaped this way looks like this:
UPLOAD_INTENT→ DIRECT_UPLOAD→ CONFIRMATION_PENDING→ SCAN_PENDING→ CLEAN→ APPROVED
#05A Boolean Was Never Going to Be Enough
The instinctive alternative to a state machine like that is a single uploaded: boolean column, flipped to true the moment the request comes back successfully. It's tempting because it's simple, and simple is usually right — except this is exactly the case where simple quietly hides the failure modes instead of handling them.
A boolean has two states. This problem has at least six failure modes, and they are not interchangeable. UPLOAD_FAILED means the bytes never made it. CONFIRMATION_FAILED means they arrived but didn't match what was promised — wrong size, wrong shape, something corrupted in transit. SCAN_FAILED means the scanner itself couldn't render a verdict — a timeout, a crashed worker, infrastructure being unavailable. INFECTED means the scanner rendered a verdict, and the verdict was bad. REJECTED means a human or a rule looked at a clean file and still said no. EXPIRED means nobody ever finished the process and the window to do so has closed.
Collapse all of that into one boolean and you lose the ability to answer the only question that matters when something goes wrong: what, specifically, happened to this file? A file stuck in SCAN_FAILED needs a retry. A file in INFECTED needs to never be served to anyone, ever, and probably needs someone told about it. Those are opposite responses to two states a boolean would have rendered identical. Explicit states aren't bureaucracy — they're the only way the system can tell those two very different problems apart later, without a human re-investigating from scratch.
#06Why "Intent" Gets Its Own Record
The state machine above starts at UPLOAD_INTENT, before a single byte has moved. That's a deliberate choice worth pausing on, because the more obvious design writes nothing to the database until the upload is confirmed — why record an upload that might never happen?
The answer is abandonment. A user requests a presigned URL, gets it, and then closes the tab, loses their connection, or just changes their mind. In the "write nothing until confirmed" version of this design, that attempt leaves no trace anywhere in the application's own records — except, potentially, an actual file sitting in a storage bucket that nothing in the database points to. Multiply that by every abandoned upload a real user base produces over months, and you get a bucket slowly filling with orphaned files that no query can find, because nothing was ever written down linking them to anything.
Creating a record — however minimal — at the moment the intent is issued, not the moment it's confirmed, turns that invisible category of problem into a solvable one. An upload that's been sitting in UPLOAD_INTENT past the window its presigned URL was valid for isn't a mystery; it's a query. Something can find it, mark it EXPIRED, and clean up whatever storage it might have touched. The cost is one extra row for every upload attempt, most of which complete normally and just move on to the next state. The benefit is that "cleanup" becomes something the system can actually do, instead of something that would require a human to notice the bucket looks bigger than it should.
#07Quarantine Is Not Approved Storage
Where a file physically lives should track what the system currently believes about it, not just where it happened to land first. This is the part of the design that's easiest to skip and most expensive to skip badly: a freshly uploaded file — even one that's passed confirmation, even one whose checksum matches perfectly — has not been scanned yet. Nothing about "the bytes arrived correctly" tells you anything about whether those bytes are safe.
The fix is structural, not procedural: two separate storage locations, not two mental categories inside one bucket. Everything lands in quarantine storage first, unconditionally, no exceptions for files that "look fine." Only a file whose scan comes back clean gets copied into approved storage — and the copy matters as much as the scan does, because it means "approved" isn't a label bolted onto a file that's still sitting wherever anything unscanned also sits. It's a different location entirely, one that nothing unscanned has ever touched.
That's a deliberately paranoid default, and it should be. The alternative — one bucket, with a database flag saying whether a given file is "safe" — puts the entire security boundary in one place that has to be checked correctly, every single time, by every piece of code that ever reads from that bucket. Get one download path wrong, forget one check in one route added eighteen months later by someone who's never read this part of the codebase, and the flag stops mattering. Separate storage doesn't have that failure mode. A download path pointed at the quarantine bucket by mistake is a bug you'll notice immediately, in code review if not before — not a silent gap that only shows up when someone finds the wrong file in the wrong place.
#08Confirmation Means More Than a 200 OK
It's tempting to treat "the upload request returned 200" as confirmation. It isn't, and the gap between those two things is exactly where quiet data corruption lives.
A successful response from a PUT to object storage tells you the storage provider accepted something. It doesn't tell you that something matches what the application was told to expect. Confirmation, done properly, means going back and actually checking, and it's checking two different things, not one. First, identity and integrity: re-fetch the object's metadata rather than trusting the client's word for it, and compute a checksum — SHA-256 is the common choice — over the bytes that actually landed, so the system knows this is the same file the client said it was sending, byte for byte. Second, and separately, a declared-type check: read the first handful of bytes to verify they match the file type the upload claimed to be, not just the extension on the filename.
That second check — often called a magic-byte check, because most real file formats start with a small fixed signature — matters more than it sounds like it should. A filename ending in .pdf proves nothing about what's actually inside the file; renaming an executable to end in .pdf costs an attacker nothing. But it's worth being precise about what this catches: a mismatched signature says the file isn't what it claims to be. It isn't a malware verdict, and neither is a matching checksum — both are integrity and identity checks, not a security scan. That's exactly why they're not the last step. A file has to pass confirmation before it's even allowed into a scan queue, but passing confirmation only means the bytes are genuine and correctly typed. Whether they're safe is a question confirmation was never built to answer.
#09Scanning Doesn't Belong in the Request Cycle
Once a file has been confirmed, it needs to be scanned before it's trusted — and that scan has to happen somewhere other than inside the original request. Malware scanning isn't instantaneous, and it isn't reliably fast; making the browser sit on an open connection waiting for a scanner to finish is a timeout waiting to happen, and it couples the user's experience to infrastructure that has nothing to do with whether their upload technically succeeded.
The natural shape for this is a background job: confirmation, on success, drops a message onto a queue instead of scanning inline. A worker process, running separately from anything answering web requests, picks that message up, fetches the file's bytes, and hands them to a scanner adapter — in development and tests, that's often something deliberately simple and deterministic, built to flag a known industry-standard test signature so the "infected" path can be verified without needing an actual virus; in production, it's a real scanning engine, something like ClamAV or a vendor equivalent. The result comes back as one of three things: clean, infected, or failed to produce a verdict at all — and only a clean result triggers the promotion into approved storage described earlier.
That third outcome is worth sitting with, because it gets flattened in a lot of designs that don't think about it carefully. SCAN_FAILED and INFECTED sound similar and mean opposite things. Infected means the scanner did its job and found a real problem — the file is genuinely unsafe, full stop. Failed means the scanner didn't manage to render a verdict at all — a timeout, a crashed worker, a dependency that was unreachable. One of those is a finding. The other is the system failing to establish anything. Treating them the same either blocks a user's legitimate document over an infrastructure hiccup that had nothing to do with them, or — worse, in the other direction — treats "we don't actually know" as good enough to let something through. Neither is acceptable, and keeping them as separate states is what makes it possible to respond to each correctly: retry a failed scan automatically, because it isn't the customer's fault; never, under any circumstance, retry your way past an infected verdict.
#010Fail Closed, Not Fail Careful
All of the preceding machinery earns its keep at exactly one moment: the instant something — a download link, an admin preview, an API response — has to decide whether a given file is allowed to be shown to anyone.
The design goal is to have exactly one place in the entire codebase where that decision gets made, rather than trusting every download path, every admin route, every future feature built by someone who's never read this article, to remember the rule on their own. A design built this way can frame it as a single predicate, doing nothing but this: a document is servable if, and only if, its scan status is clean. Nothing else. Not "uploaded." Not "confirmed." Not "no scan result recorded yet, so probably fine." Clean, specifically, or the file doesn't go out.
That's fail-closed thinking, and it's a genuinely different posture from the more common instinct, which is fail-careful: remember to check the right condition, in the right place, every time a new route touches file access. Fail-careful works exactly as well as everyone's memory does, forever, across every future engineer who touches the codebase. Fail-closed doesn't rely on memory at all — an unscanned file, a still-scanning file, and a file the scanner never managed to check are all, by construction, in the same bucket as an infected one: not servable, no exceptions, until something explicitly proves otherwise. It's worth being precise about what this is at the planning stage: a rule laid out for the domain layer to enforce, not a claim that every download path already routes through it — which of those routes gets built first is a build-order detail, not a design one. But the value of writing the rule down this explicitly before the rest of the pipeline exists is that "servable" becomes something one function can answer correctly by construction, instead of something every future contributor has to get right by discipline.
#011A Document Upload Is Actually a Lifecycle
Zoom out far enough and "file upload" stops being an accurate name for what's being built. A document, in any system that takes this seriously, doesn't just get uploaded once and sit there. It gets replaced when the first attempt was the wrong file. It accumulates versions, because a reviewer needs to know what changed and when. It gets scanned, every single time, not just the first time. It gets rejected, sometimes for reasons that have nothing to do with malware — wrong document type, expired, illegible. It expires, if nobody ever finishes what they started. It gets retried, because networks fail and scanners time out and none of that should be the end of the story. It gets looked at by two different audiences with two different levels of access — the person who submitted it, and the reviewer deciding whether to approve it — and those two audiences should not, structurally, be able to see the same thing at the same stage of trust.
Put those together — replacement, versioning, scanning, rejection, expiration, retry, dual audiences — and "upload" was never really the feature. It was the entry point to a lifecycle that a huge number of systems build as if it were the whole thing, because on the happy path, a four-step model looks exactly like a five-stage one. The difference only shows up when something goes wrong, which is precisely the moment you'd most want the system to already know the difference.
#012The Bar That Actually Moves
Come back to that progress bar. It filled up. It said 100%. Everything about that number was true, and none of it was the point.
The bar tells you that bytes moved from one place to another. It was never built to tell you whether the system on the other end has any reason to trust what it received — that's a separate transition, running underneath the UI entirely, and it looks less like a percentage and more like a chain of custody:
UNTRUSTED→ VERIFIED→ SCANNED→ APPROVED→ SERVABLE
#013The Progress Bar Was Never Lying
It just wasn't answering the question everyone assumed it was answering. The bar answers one question: did the bytes move? A production upload pipeline has to answer another: can the system trust what arrived? Those are different questions, running on different timelines, and the entire discipline of building this correctly comes down to refusing to let the fast one stand in for the slow one. Treating them as one question is how a file upload quietly becomes a security problem.
Uploaded is a storage event. Approved is a trust decision. They were never the same thing.

