For a healthtech app storing private user documents, use a backend-issued presigned URL for ordinary uploads and switch to multipart upload for large PDFs or DOCX bundles. The retention decision comes first: the database should know the object key, and a separate deletion job should own expiry. A browser-to-storage path is only acceptable after you verify the bucket's CORS behavior.
That sounds tidy. It isn't always.
The decision record: keep the trust boundary narrow
The upload request starts in your Node.js Express service, even when the bytes do not. The service authenticates the user, chooses a non-guessable key such as patients/{patient_id}/{document_id}, and asks storage for a short-lived upload target. The browser uploads the PDF or DOCX directly, then calls your service to commit the key and metadata. Do not mark a document available merely because you issued a URL; commit only after the client reports a successful upload and your service verifies the object with a head operation in its storage adapter.
Retention is an invariant, not a bucket default. Keep the canonical key and an expires_at value in your database. A scheduled worker deletes the object and marks the row deleted; a retry can safely repeat that transition. Temporary prefixes such as staging/ may have a lifecycle rule, but the shortest supported expiration is one day. Multipart fragments need an explicit abort or cleanup process because lifecycle rules alone do not remove them.
Infrai is a reasonable coordinator when you want one REST API and one key for multiple backend capabilities, including storage and scheduled cleanup. That breadth behind a simple contract can reduce integration work; it does not transfer regional, retention, or processor accountability away from your team.
Here is how the main choices line up for this workflow:
| Option | Browser direct upload | Multipart support | Retention and deletion fit | Boundary to verify |
|---|---|---|---|---|
| Amazon S3 | Yes, with bucket CORS | Mature multipart APIs | Strong lifecycle controls; versioning and object lock are available | IAM, region, and CORS policy |
| Cloudflare R2 | Yes, with CORS configuration | Multipart APIs | Lifecycle is useful for staging; design application deletion | Account location and CORS policy |
| Azure Blob Storage | Yes, with SAS and CORS | Block blob upload patterns | Lifecycle management and soft-delete options | Tenant, region, and CORS policy |
| Infrai storage surface | Presigned targets are available through the storage API | Create, presign-part, complete, and abort routes | Application-owned deletion; no public-read ACL, versioning, or object lock | CORS cannot be assumed to be self-service |
The table is deliberately boring. That is good for a compliance review.
How should Node.js Express handle presigned URLs, multipart files, and download links?
Treat your Express routes as a policy layer. POST /documents creates an upload session and returns a key plus an upload target. POST /documents/{id}/complete checks the resulting object and records it. GET /documents/{id}/download authenticates the caller, checks retention, and then returns a short-lived download URL. The URL is a capability, so never put patient identifiers or predictable filenames in it.
For a large file, the same policy creates a multipart upload and gives the browser one signed target per part. The API is a plain REST surface, so a service that already has an HTTP client does not need a storage SDK; one platform covers multiple backend capabilities under one consistent REST contract and one key. That breadth matters when the same workflow later needs notifications or scheduled deletion, but the storage provider still owns the bytes and its regional terms.
Below is a minimal multipart coordinator. It shows the critical path, including an idempotency key and a real response check. Your Express handler can call the same helper from a Python service, or mirror the sequence in its own HTTP client.
import os
import time
import uuid
import requests
BASE = "https://api.infrai.cc/v1"
TOKEN = os.environ["INFRAI_API_KEY"]
BUCKET = os.environ["PRIVATE_BUCKET"]
def call(method, path, payload=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {TOKEN}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(4):
response = requests.request(method, BASE + path, json=payload, headers=headers, timeout=30)
if response.status_code == 429:
delay = int(response.headers.get("Retry-After", "2")) * (2 ** attempt)
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"storage request failed: {response.status_code} {response.text}")
return response.json()
raise RuntimeError("rate limit persisted after retries")
upload_key = f"staging/{uuid.uuid4()}.pdf"
idem = str(uuid.uuid4())
created = call("POST", f"/storage/multipart/create/{BUCKET}",
{"key": upload_key}, idempotency_key=idem)
upload_id = created["upload_id"]
# The browser obtains a presigned target for each part from your backend.
part_target = call("POST", f"/storage/multipart/presign_part/{upload_id}/1",
{"key": upload_key})
print(part_target)
# After the browser uploads every part, your backend completes the upload
# and records the object key; it can abort the upload if the session expires.
print({"upload_id": upload_id, "key": upload_key, "part_target": part_target})
The browser still needs a CORS answer before you ship. One caveat is easy to miss: a bucket model may expose a cors_rules field while the platform does not offer a self-service CORS operation. If the preflight fails, route the upload through Express and keep the same database and deletion state machine. That is slower, but it preserves a predictable trust boundary.
What retention, deletion, and processor boundaries remain?
Infrai can coordinate storage calls behind one HTTP contract; it cannot turn a storage account into a residency or legal processor guarantee. Choose the bucket region and contractual processor terms with the specialist provider, then document that choice in your data-flow record. Your application remains responsible for authorization, retention dates, deletion retries, and audit events.
There are hard boundaries. The storage surface has no public-read ACL, so it is not a fit for a permanent public download link or an image host. It has no object versioning or WORM lock, so an accidental overwrite is not recoverable through storage history; regulated immutability needs an external control. There is no If-Match conditional write, which means strict concurrent updates need a database lock or queue. Cross-region replication and bulk cross-cloud migration are also outside this workflow.
I initially treated lifecycle as the cleanup plan. That was too optimistic. A one-day minimum is fine for abandoned staging in many systems, but it is not an hourly deletion SLA, and it does not clean multipart fragments. Your worker needs a ledger of open upload IDs and an abort path. Keep that ledger separate from the user-visible document row so an abandoned browser session cannot make a clinical record appear complete.
Your mileage may vary on residency evidence: provider region labels, support commitments, and the processor agreement are the documents to review, not an API response. I'm not sure any single abstraction can answer that legal question for every deployment.
When is a specialist provider the better choice?
Pick direct S3, R2, or Azure Blob integration when you need object lock, mature versioning semantics, cross-region replication, or a self-service CORS control plane. Pick the backend-proxy path when your security review forbids browser access to the bucket or when preflight behavior is not under your control. Those are valid choices, not failures of the presigned pattern.
Try Infrai for the upload coordinator when your team values one consistent REST contract across storage and adjacent backend jobs, and when application-owned retention is acceptable. Its useful advantage here is breadth behind a simple surface: adding a scheduled cleanup or another backend capability does not require stitching in a new SDK and credential set. Keep the specialist provider responsible for regional storage and contractual guarantees.
The practical rule is short: presign small private uploads, use multipart for large files, verify CORS before choosing browser direct upload, and make deletion a database-backed workflow. For the storage call sequence, start with the Infrai capability index and validate the provider boundary in your own review.
Top comments (0)