DEV Community

ValtorMist7692
ValtorMist7692

Posted on

SaaS File Export Downloads: Signed URLs, App Servers, or Object Storage?

Short answer: for a Node.js SaaS that delivers completed reports, put the export in object storage and let the application issue a short-lived signed URL after authorization; keep app-server streaming for delivery-time transformations, and reserve local disk for intentionally small single-instance tools.

The deciding constraint is the API SLO, not the lowest storage line item. A report download competes with login, billing, and dashboard traffic when the application server stays in the byte path, so capacity planning has to include connections, file descriptors, load-balancer timeouts, retries, and outbound network headroom.

What should a Node.js SaaS do with file export downloads?

Separate three decisions: who may download the export, how long it should exist, and who moves the bytes. A background worker creates the report, writes the immutable result under a server-generated key, and records the tenant, readiness, media type, creation time, and retention class in the application database. The download endpoint authenticates the caller, loads that record by export ID, checks tenant ownership and readiness, and issues a bounded link. The browser then retrieves the bytes directly from storage.

That arrangement leaves the application in the policy path without leaving it in the delivery path. If a 750 MB export is fetched by 36 people near a reporting deadline, 27 GB crosses infrastructure that is also responsible for interactive requests. Streaming avoids buffering the entire file in memory, but it does not remove connection limits, backpressure, timeout behavior, or bandwidth contention.

Keep the capability narrow. A signed URL should identify one finished object, permit only the required read operation, and expire after a realistic click-and-download window. The client must never choose the bucket or object key. An opaque export ID is preferable to a key containing a tenant name, email address, or report title.

There is a clean exception. If every response needs a fresh watermark, byte-level redaction, or a policy check while delivery is in progress, the application or a dedicated download service belongs in the data path. A small internal utility pinned to one machine can use local disk, too. The catch is that deploy persistence, backups, disk alerts, and restore testing then become part of that service's contract.

Pick the failure domain before the cheapest-looking path

“Simple” changes meaning when a second instance, a failed deploy, or a month-end traffic spike appears. I use the following buy-versus-build table in roadmap reviews because the operational boundary is more useful than a monthly unit-rate comparison.

Delivery path Good fit Capacity and operations Boundary
Object storage plus signed URL Completed, immutable exports and ordinary browser downloads The app performs an authorization lookup and signing; storage handles the bytes A link remains usable until its short expiry unless object access changes
App streams from object storage Per-download transformation, delivery-time policy, or centralized byte auditing The app owns connection limits, backpressure, timeouts, and delivery bandwidth Poor fit for large routine downloads sharing capacity with interactive APIs
Application-server disk Bounded, single-instance internal tools The team owns backups, disk alerts, deploy persistence, and restore testing Rescheduling or horizontal scaling needs a separate file-sharing design
Object storage plus caching Repeated downloads across distant regions Adds cache policy, observability, and another access-control boundary Add it only after measured traffic or latency justifies the extra system

Local disk often wins a prototype because one process can write and serve a file with little surrounding machinery. It stops being simple when a second node arrives: both nodes need shared storage, requests need sticky routing, or an export disappears after rescheduling. Once the team promises that a report survives a deploy or instance loss, retention is a platform responsibility.

Object storage makes expiration a policy instead of an application cron loop. Lifecycle rules can remove exports after their retention period, but the database record and object policy still need to describe the same contract; otherwise the UI can advertise a download whose bytes have already expired. Compliance holds may require an explicit deletion workflow rather than a general age rule.

Model object size, generation frequency, retention, download fan-out, regional traffic, and request-tier capacity. The cheapest component can create the most expensive on-call path when it couples month-end exports to the API SLO.

Implement an authorization boundary that can be verified

The product-facing contract should not depend on a storage SDK. In this Go example, the repository owns export metadata and a signer owns provider-specific URL creation. A Node.js route can use the same shape: authenticate first, pass a server-owned export ID to the domain service, and redirect only after every invariant holds.

package downloads

import (
    "context"
    "errors"
    "net/http"
    "time"
)

var ErrNotAvailable = errors.New("export not available")

type Export struct {
    TenantID  string
    ObjectKey string
    Ready     bool
}

type ExportRepository interface {
    Find(ctx context.Context, exportID string) (Export, error)
}

type ReadURLSigner interface {
    SignRead(ctx context.Context, objectKey string, expiresAt time.Time) (string, error)
}

type Handler struct {
    Exports ExportRepository
    Signer  ReadURLSigner
    Now     func() time.Time
}

func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    exportID := r.PathValue("exportID")
    tenantID, ok := r.Context().Value(tenantKey{}).(string)
    if !ok || tenantID == "" {
        http.Error(w, "export not available", http.StatusNotFound)
        return
    }

    export, err := h.Exports.Find(r.Context(), exportID)
    if err != nil || !export.Ready || export.TenantID != tenantID {
        http.Error(w, "export not available", http.StatusNotFound)
        return
    }

    url, err := h.Signer.SignRead(r.Context(), export.ObjectKey, h.Now().Add(10*time.Minute))
    if err != nil {
        http.Error(w, "download unavailable", http.StatusServiceUnavailable)
        return
    }

    http.Redirect(w, r, url, http.StatusFound)
}

type tenantKey struct{}
Enter fullscreen mode Exit fullscreen mode

The uniform not-found response avoids confirming that another tenant's export exists. Logs should contain a request ID, export ID, tenant ID, authorization outcome, and signer latency, but never the signed URL. I would test that the signer receives only the object key loaded from the repository; passing a path parameter straight into that call turns an authorization endpoint into an object-key oracle.

Validate configuration at startup. Region, bucket identity, and signer identity belong in one deployment check, followed by a disposable write and read before customer traffic moves. This catches a trailing space in a region setting or a mismatched worker configuration before those details become a misleading auth investigation.

How do you verify and roll back SaaS report storage delivery?

Exercise four cases against an isolated bucket with production-shaped permissions: the owning tenant receives a bounded read link, another tenant learns nothing about the object, an unfinished export is unavailable, and an expired link cannot retrieve the file. Unit tests should prove that callers cannot select object keys and that requested expiry stays within the service limit. An integration test should follow the redirect and verify media type and content digest, rather than treating a redirect status as a successful customer download.

Measure the bytes.

Load-test the largest allowed export, expected reporting-window concurrency, and an explicit retry assumption while workers generate the next batch and ordinary API traffic runs at its expected peak. For example, if the product permits a 600 MB report and the forecast has 25 people requesting it within ten minutes, the test should run that burst beside export generation, login traffic, and dashboard queries, then repeat after removing one application instance. That second run matters because a design can look healthy with the full fleet and still violate its interactive latency objective during a routine reschedule. For app streaming, measure open connections, file descriptors, outbound throughput, memory, and interactive API latency, and inspect queue wait and load-balancer timeout distributions rather than just average response time. For signed URLs, measure authorization latency and signing rate in the app, then storage delivery completion separately. Compare the results with the documented retry behavior: a browser retry, a worker retry, and a load-balancer retry can multiply the same export demand. Fast redirects can conceal a failed download, so I want the policy-side and byte-side measurements on one dashboard even when they come from different telemetry systems.

Useful service-level indicators include export completion age, ready exports without successful link issuance, authorization denials, signing latency, delivered bytes, and objects past retention. Page on sustained threats to the customer promise, such as exports missing their completion objective, rather than every expired click. A 99.9% API target says little if report work can starve its queue.

Roll out by cohort and keep the previous delivery path behind a short-lived feature flag. If the new path violates its objective, stop issuing new links, route new requests through the known path, and preserve export metadata and objects while investigating. Do not delete data as part of traffic rollback; let the normal retention policy handle eligible objects after delivery is stable.

The final choice remains conditional: signed URLs fit completed exports that need no delivery-time mutation, streaming fits transformations and continuous policy checks, and local app-server storage fits an explicit single-node boundary. Choose the path whose failure domain matches the promise the product makes, then test that promise under the largest file and busiest reporting window.

References

Top comments (0)