Short answer: use a presigned single-object upload for ordinary classroom images, then add multipart only when an image is large enough that retrying the whole object is operationally painful. The retention policy should decide what metadata and temporary parts survive, not the upload API.
For an edtech platform, the useful unit is a tenant-scoped export of product images: course thumbnails, workbook covers, and lesson illustrations. The browser uploads directly to object storage; an application service authorizes the object key and later produces an export containing only that tenant's retained objects. This keeps application servers out of the image byte path, but it makes deletion, expiry, and incomplete uploads part of the design.
What is the bill actually made of?
The visible object size is only one term. The harder-to-see terms are duplicate uploads after a timeout, abandoned multipart parts, export staging objects, and retained metadata that still points at deleted images. A 2 MB thumbnail uploaded twice is less interesting than a 700 MB source image whose failed retry leaves temporary parts behind.
I start by measuring the distribution of object sizes and the number of objects created per tenant. Then I put a deadline on each state: pending upload, committed image, export snapshot, and deleted object. Those states need separate retention rules. A pending record can be short-lived. A committed image follows the tenant's policy. An export snapshot needs a documented expiration, because it is a second copy with a second access path.
The deliberate trade-off is simple: do not keep every failed attempt forever. The cost is forensic detail. When a learner reports a missing cover, you may have only an upload identifier, checksum, actor, and timestamps rather than the discarded bytes. That is acceptable if the event record is durable and the product can explain the state transition.
Retention is a state decision.
| Upload shape | Good fit | Retention surface | Main failure to test |
|---|---|---|---|
| Presigned single object | Modest images and restartable transfers | Intent plus committed object | Retry after a successful write |
| Multipart | Large images or costly restarts | Intent, parts, completion, and abort | Abandoned parts after expiry |
Which browser direct upload survives a real failure?
A presigned URL gives the browser temporary authority to write one object. It is a good fit when the object is modest, the client can retry the complete transfer, and the server can issue fresh authorization after a timeout. The application still owns the key, tenant binding, content constraints, and final commit decision. A successful HTTP response is not the same thing as a business-level commit.
Multipart upload divides one object into parts and lets the client retry a failed part. That matters for large images, unstable mobile connections, and long transfers where restarting from byte zero is wasteful. It also creates more state: an upload identifier, part numbers, checksums, completion ordering, and an abort or expiry path. A multipart design without a janitor is a retention leak wearing a performance feature.
The browser path should therefore be explicit.
from dataclasses import dataclass
@dataclass(frozen=True)
class UploadIntent:
tenant_id: str
object_key: str
mode: str
expires_at: int
def choose_mode(size_bytes: int, retry_from_zero_is_acceptable: bool) -> str:
if retry_from_zero_is_acceptable:
return "single_object"
if size_bytes > 100 * 1024 * 1024:
return "multipart"
return "single_object"
def can_commit(intent: UploadIntent, actor_tenant: str, now: int) -> bool:
return (
intent.tenant_id == actor_tenant
and now < intent.expires_at
and intent.object_key.startswith(f"tenants/{actor_tenant}/")
)
The threshold in this example is a policy placeholder, not a universal limit. Calibrate it from transfer duration, failure rate, browser behavior, and the cost of restarting. Do not turn a convenient number into a protocol fact.
How do retention and deletion change the comparison?
Deletion has at least three questions: can a new upload still complete, can an old export still read the object, and can a later request accidentally recreate access to the same key? Expiring an authorization answers only the first part. The export service must take a tenant-scoped snapshot of object identifiers, re-check authorization when it reads them, and avoid accepting arbitrary keys supplied by the browser.
Use unique, server-generated keys rather than overwriting a predictable filename. Record the tenant, object key, upload intent, content length, checksum when available, creator, creation time, and deletion time. On deletion, mark the application record first, remove the object through the storage boundary, and make export selection ignore the tombstoned record. The exact ordering and retry behavior belong in an idempotent job, because network failures can occur after the storage request succeeds.
For multipart, retention includes unfinished parts. The cleanup job should find intents past their deadline, abort them, and record the result. For single-object uploads, the cleanup job instead handles intents whose object never reached the committed state. Both workflows need a reconciliation report comparing committed metadata with objects found under the tenant prefix. That report is where orphaned images become visible before an export surprises someone. It also gives the operations team a bounded question to answer: is this object pending, committed, exported, or orphaned? Without that classification, a deletion request can look successful while an old export or an untracked object remains available through a separate path.
A decision rule that stays maintainable
Choose the simplest upload mode that meets the measured failure and object-size requirements. A single-object presigned flow has fewer states and a smaller deletion surface. Multipart earns its complexity when retrying the entire object is a meaningful reliability problem. The choice should be per upload class, not a permanent property of the whole platform.
Test the unhappy paths: an expired authorization, a browser retry after a successful write, a completion request duplicated by the client, a deletion racing an export, and a multipart upload abandoned halfway through. Assert tenant isolation in every test. In observability, log an opaque upload intent ID and tenant ID, but never put temporary credentials or signed URLs in application logs.
The operational change that moves the retention problem is a state machine with a short pending lifetime and a named owner for cleanup. The protocol choice follows from that state machine. It should not be used to hide missing deletion rules.
References
- RFC 9110: HTTP Semantics — https://www.rfc-editor.org/rfc/rfc9110
Further reading
See the References section for the standards source used here.
Top comments (0)