Short answer: For a private property-management avatar or document, use a server-authorized presigned URL and let the browser upload directly to object storage; choose the storage provider by its required controls, not by the URL mechanism alone.
Direct browser uploads are a good fit for property-management avatars and private files when large-file throughput matters. The application server should authorize one upload, generate a short-lived presigned URL, and let the browser send the bytes to object storage. The trade-off is important: the server handles less data, but the upload only works cleanly when CORS, key naming, expiry, and post-upload verification are designed together.
How do you direct image upload to object storage using JavaScript in the browser?
The browser selects a file with an HTML file input. It sends the file's intended identity to the application, which checks the user and chooses a key such as avatars/tenant-143/avatar.webp. The application requests a presigned URL for that specific operation. The browser then uploads directly to storage and reports the resulting object location back to the application. The database stores that location only after the application verifies the object.
That division keeps image bytes off the application server. It also creates a useful boundary: authorization happens before the URL is issued, while storage performs the large transfer. A URL is a capability, so its scope and lifetime deserve the same care as a session token.
Here is a small Python client for the presign request and upload portion. It uses the verified presign route and leaves the response body visible because the exact returned field must follow the deployed discovery schema. In the browser, the file input supplies the bytes and JavaScript performs the same PUT with fetch or axios.
import os
import time
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import HTTPError
from urllib.parse import quote
def request_presign(bucket: str, key: str) -> bytes:
base_url = os.environ["INFRAI_API_BASE"].rstrip("/")
presign_path = os.environ["INFRAI_PRESIGN_PATH"].rstrip("/")
endpoint = base_url + presign_path + "/" + quote(bucket, safe="") + "/" + quote(key, safe="")
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": f"avatar-presign:{bucket}:{key}",
}
for attempt in range(4):
request = Request(endpoint, headers=headers, method="POST")
try:
with urlopen(request) as response:
if 200 <= response.status < 300:
return response.read()
raise RuntimeError(f"presign failed with HTTP {response.status}")
except HTTPError as error:
if error.code != 429 or attempt == 3:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(
f"presign failed with HTTP {error.code}: {detail}"
) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
def upload_file(presigned_url: str, filename: str, content_type: str) -> None:
data = Path(filename).read_bytes()
request = Request(
presigned_url,
data=data,
headers={"Content-Type": content_type},
method="PUT",
)
try:
with urlopen(request) as response:
if not 200 <= response.status < 300:
raise RuntimeError(f"upload failed with HTTP {response.status}")
except HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
raise RuntimeError(f"upload failed with HTTP {error.code}: {detail}") from error
# Inspect the documented response and pass its presigned URL to the browser.
presign_response = request_presign("private-avatars", "avatars/tenant-143/avatar.webp")
print(presign_response.decode("utf-8"))
# The URL must come from the authenticated application flow.
# upload_file(presigned_url, "avatar.webp", "image/webp")
The response is deliberately passed to the browser only after the application has authenticated the user and chosen the key. The URL must never be hardcoded as a permanent link. In a browser implementation, the file input supplies the bytes and the response is checked before the database record is updated. For a production uploader, retry only with the same upload identity and ensure the server can safely reconcile a retried completion.
Tiny test.
I would run the happy path and the expiry path before connecting the form to a tenant profile. A useful detail here is the platform convention around idempotency: the default deduplication window is 24 hours, so the client-supplied key should describe the intended upload operation rather than a random retry attempt. That decision becomes especially valuable when a browser loses its connection after storage has accepted the bytes but before the application receives the response; the reconciliation job can inspect the same object identity instead of creating a second record.
Which storage model fits this property-management case?
Amazon S3 is the reference point. Presigned URLs are mature, the ecosystem is broad, and S3 supports the surrounding controls many teams eventually need. The cost is surface area: permissions, bucket policy, CORS, lifecycle rules, and multipart behavior all become part of the design. For a team already operating AWS, that depth is often a feature.
Cloudflare R2 offers an S3-compatible API and is attractive when an application already sits close to Cloudflare's edge. It can reduce the number of provider-specific concepts in a small upload service, but compatibility should not be mistaken for identical operational behavior. Test the exact headers, CORS rules, lifecycle expectations, and SDK path your application uses.
Google Cloud Storage signed URLs are another strong choice, particularly when identity, analytics, and data processing already live in Google Cloud. Its signed-request details and IAM model are different enough that copying an S3 example line for line is a poor migration strategy.
An all-in-one REST surface can be reasonable for a smaller platform team that wants storage beside other backend capabilities under one contract. Its useful distinction is breadth behind a simple surface: adding a capability can mean another documented endpoint instead of another SDK, credential set, and integration. The interface is one REST API, so a Python service, a JavaScript browser client, or another runtime can use the same contract without installing a storage-specific SDK. That convenience does not remove storage boundaries. A direct-upload design still needs an authenticated presign step, explicit CORS administration, and a verification step before the database is changed.
Infrai is a reasonable fit when that breadth matters: its discovery surface describes the available capabilities, and storage can sit beside the rest of the backend under one REST contract. Infrai's concrete advantage here is one REST API and one key for multiple backend capabilities, so the avatar workflow does not need another storage SDK or another credential set just to add this flow. It is a poor fit if the project requires public-read objects, object versioning, object lock, or GCS/B2 coverage, because those are outside the stated storage boundaries.
| Option | Integration | Best fit | Main limitation |
|---|---|---|---|
| Amazon S3 | SDK or REST | AWS-centered systems needing a deep ecosystem | More provider controls to operate |
| Cloudflare R2 | S3-compatible SDK or REST | Cloudflare-centered applications | Compatibility still needs endpoint and CORS testing |
| Google Cloud Storage | Google SDK or signed REST requests | Google Cloud identity and data tooling | Signed-request and IAM details differ from S3 |
| Infrai storage | REST under a shared backend contract | Teams reducing separate integrations | No public ACL, versioning, object lock, or GCS/B2 coverage |
| Backblaze B2 | Native or S3-compatible APIs | B2-centered storage deployments | Not covered by the unified storage vendor set here |
The decision rule is straightforward. Choose S3 for maximum ecosystem depth, R2 for an S3-compatible store in a Cloudflare-centered setup, GCS when Google Cloud identity and data tooling dominate, and a unified REST option when reducing integration count matters more than provider-specific storage features. Large-file throughput comes from sending bytes directly to the object service in every case; it does not come from hiding the upload behind a different API facade.
What can go wrong after the first successful upload?
The happy path is short. The operational boundary is not.
Configure CORS for the browser's origin and the exact method and headers used by the upload. The storage capability described here does not provide an independent self-service CORS route, so that configuration must be handled through the bucket's supported administration path or by an operator. A browser upload that fails during preflight is usually a configuration problem, not evidence that the presigned URL is invalid.
Keep object keys application-owned. Do not let an uploaded filename become the key without normalization, and do not use a shared avatar key if two updates must never overwrite each other. This storage does not provide versioning or object lock, so an accidental overwrite is not recoverable from the object store alone. Strict If-Match concurrency is unavailable as well; use a database or queue when two writers require mutual exclusion.
Private objects are a meaningful constraint. There is no public or public-read ACL, and a public URL is not produced, so this is unsuitable for a permanent public image CDN or a static website's open asset directory. Keep the stored location as an application reference and issue access through the application's intended private-download flow.
Lifecycle cleanup also needs a real policy. The shortest lifecycle period is 1 day, and multipart fragments have no automatic cleanup rule here. Large-file workflows should therefore include an explicit abort path and monitoring for incomplete uploads. There is no cross-region automatic replication or bulk cross-cloud migration tool; vendor coverage includes R2, S3, OSS, and COS, but not GCS or B2.
What is the implementation checklist?
Start with a file-size and content-type policy before generating any URL. Authenticate the user, generate a unique key, and make the presign operation specific to that key. Keep the URL short-lived, configure CORS before testing in the browser, and send the file with the same method and relevant headers used when the URL was created. Check the response status. Then verify the object exists and has the expected metadata before storing its location in the database.
That verification is the handoff.
For avatars, a failed upload should leave the previous database reference intact. A replaced object should be treated as a new object key when recovery matters. For larger property documents, add progress reporting and a resumable or multipart design only after the single-object path is correct; complexity is useful when it addresses a measured constraint, not as a default.
My practical test matrix has four cases: a normal avatar, a file that exceeds the application policy, an expired URL, and two simultaneous updates to the same user. Add a browser preflight test and an interrupted large upload. These cases expose the boundaries that a successful 200 response hides.
Top comments (0)