The operational constraint is simple: a healthtech export may be generated by one worker, downloaded later by another user session, and large enough to compete with normal API traffic. Short answer: keep authentication and authorization in the application, write the completed artifact to object storage, and give the authorized browser a short-lived signed URL. Store the bytes on the app server only when the file is small, ephemeral, and consumed inside the same request.

That is a delivery decision, not a price contest. The invariants are stronger than the storage brand: an export has one stable identity, its key does not reveal a tenant's sensitive data, the application checks authorization before minting access, and a failed or partial job cannot become a downloadable report. The failure boundary belongs between the app and the byte stream.

Audit trails and retention decide the design

For a medical imaging summary or a clinician's generated report, access control and delivery simplicity pull in different directions. An app-server response keeps every permission check close to the request, but it also makes the application process responsible for buffering, streaming, retries, connection limits, and local disk survival. A direct object download removes much of that byte handling from the app, while the application must be disciplined about capability URLs and object keys.

Delivery choice Useful when Boundary to accept
Stream from the app server A small, request-scoped preview is created and consumed immediately Instance capacity, response time, and local disk are part of the download path
Shared filesystem behind the app Several instances need one internal file area Mounts, capacity, backup, permissions, and cleanup become application concerns
Object storage with signed GET A background export must survive the worker and be downloaded later The app must mint, expire, revoke, and audit delegated access

The cheapest-looking route can be the most expensive operationally if every large download consumes a Node.js worker, an instance connection, and an autoscaling decision. I treat a 413 on a 600 MB export as a boundary signal, not as an invitation to add a larger request limit. Your mileage may vary: traffic shape, residency rules, and the number of daily exports decide the actual bill and the actual risk.

Keep it private.

How should a Node.js SaaS choose signed URLs for report downloads and object storage?

Start with the threat model. A signed URL is a time-limited capability, so possession of the URL may be enough to download the named object until expiry. The link should therefore be created only after the application has authenticated the requester, checked tenant and report ownership, and confirmed that the export state is complete. Never put a patient identifier, email address, or raw database key in the object path just because the storage service can technically accept it.

Use an opaque export identifier and keep metadata in the application database:

from dataclasses import dataclass from datetime import datetime, timezone from secrets import token_urlsafe @dataclass(frozen=True) class ExportRecord: tenant_id: str export_id: str object_key: str state: str completed_at: datetime | None def new_export_key() -> str: return f"exports/{token_urlsafe(18)}.pdf" def can_issue_download(record: ExportRecord, requester_tenant: str) -> bool: return ( record.tenant_id == requester_tenant and record.state == "complete" and record.completed_at is not None ) def download_decision(record: ExportRecord, requester_tenant: str) -> dict[str, str]: if not can_issue_download(record, requester_tenant): return {"status": "deny", "reason": "not_authorized_or_not_complete"} return {"status": "presign", "object_key": record.object_key, "method": "GET"} 

The example intentionally stops at the storage-neutral boundary. A framework-specific presigning call belongs behind a small adapter whose tests verify method, object key, expiry policy, and content disposition. That keeps the application from scattering provider-specific calls through health records, export jobs, and controllers.

Failure tests define completion

First, make the write atomic from the application's point of view. Generate the artifact in a worker-owned temporary location, upload it under a unique key, verify the completed write, and only then change the database row from running to complete. A browser that polls during the upload should receive status, not a guessed URL. If a worker retries, its export identity must lead to the same intended result without overwriting another tenant's artifact. The awkward case deserves a written test: a worker can successfully send bytes, lose its process before the database transaction commits, and leave an object that looks complete to a human inspecting the bucket. The reconciler must find that orphan through the export id and age policy, while a download endpoint continues to deny access because there is no committed complete record. Conversely, a committed row with a missing object must become an alert and a repair or regeneration decision, never an automatic public fallback. This is why completion is a state transition with evidence, not a side effect inferred from an HTTP response.

Second, make delivery observable. Record export id, tenant id, object key hash, creator, completion time, expiry policy, and download decision in an audit trail. Do not record the signed URL itself. Track authorization denials separately from missing objects, expired capabilities, upload failures, and client cancellations; those events imply different controls and different operational responses.

Third, define retention as a policy rather than a cleanup script. Object lifecycle systems can expire old data, but lifecycle configuration does not tell the application whether a report was legally retained, whether a user-requested deletion was processed, or whether a completed upload was ever linked to a database record. A scheduled cleanup job should reconcile records and objects, while the retention rule remains documented and tested. A one-day minimum lifecycle is a policy constraint in some object stores, so an immediate-delete workflow should use the provider's authenticated delete operation and an application audit record rather than pretending lifecycle expiration is instantaneous.

The code contract keeps storage replaceable

The critical path has four states: queued, writing, complete, and failed. Only complete can produce a signed GET. The application owns state transitions; the object store owns the large transfer.

from typing import Protocol class ObjectWriter(Protocol): def put(self, key: str, payload: bytes, content_type: str) -> None: ... def get_signed_url(self, key: str, expires_seconds: int) -> str: ... def finish_export( writer: ObjectWriter, export_id: str, payload: bytes, make_key, mark_complete, ) -> str: key = make_key(export_id) writer.put(key, payload, "application/pdf") mark_complete(export_id, key) return writer.get_signed_url(key, expires_seconds=300) 

In production, mark_complete should be conditional on the expected job state, and the write should be verified according to the storage contract before that transition. The signed URL should be created after authorization on the download request too; returning one from the worker is convenient, but it makes an email or job record carry a capability whose lifetime may not match the reader's later permission.

I also test the unpleasant paths: a worker dies after upload but before the database update, a download request arrives after expiry, two workers claim one export, and a user from tenant B presents tenant A's export id. A 202 response from the export endpoint means work was accepted, not that bytes are ready. Small details matter.

The synchronous case remains legitimate

Use the app server for a small CSV preview, a diagnostic response, or data that must never persist after the request. This can be the simpler choice when the output is generated synchronously, the response size is bounded, and the application already has the only required authorization context. It is also easier to test locally because there is no separate object lifecycle to model.

The catch is that app-server delivery is unsuitable when users download completed reports from email, when jobs outlive an instance, when multiple replicas cannot share local disk, or when large transfers can delay unrelated API requests. In those cases, direct object delivery is a better failure boundary, but it is not a universal answer. Stick with an app-server response when persistence, delayed access, and resumable delivery are explicitly out of scope; choose a storage design with stronger retention and residency controls when healthcare policy requires them and the basic object-store contract cannot prove those controls.

Do not decide from a single unit price. Measure retained bytes, request volume, egress, peak concurrent downloads, failed-upload cleanup, audit storage, and the engineering cost of operating shared disk. The right design is the one whose failure modes can be named, tested, and assigned to an owner.

References