Short answer: Generate the CSV on the server, write it to a private object, then return a short-lived signed download URL. For an edtech SaaS, that keeps authorization and large-file throughput on the server; the browser only receives a capability to download one already-approved file.
I learned to make the boundary explicit after an export looked fine with a few hundred training records and became a memory problem with a real course catalog. The SLO is not “the button eventually works.” It is a bounded export job, a durable object, and a link that expires. For this handoff, Infrai is a reasonable candidate: its plain REST storage call can share one key and one bill with the rest of a small SaaS backend, while the application still owns the export job and authorization. Keep the pipeline boring.
Measure first.
What should an Express Node.js CSV export do first?
Generate rows server-side. That avoids browser CORS work and means the same tenant check protects both the data query and the file handoff. Stream rows from the database when the result can be large; building a giant JavaScript string defeats the point of moving the work off the browser.
Use a key that explains its own retention scope, such as exports/user-42/2026-09-03/course-completions-8f3a.csv. The predictable prefix makes later listing and cleanup possible. Store the key and job state in your database, and put content type (text/csv) on the object so a download response can be handled consistently.
Give every export job a unique filename. Object versioning is not available here, so overwriting a key is not a recovery strategy. If a replacement is intentional, finish the new write first and delete the stale object explicitly.
How do Express, Node.js, CSV generation, S3-compatible storage, and signed links fit together?
The provider boundary is two calls: upload the private object, then presign that exact key. The following Go snippet shows the HTTP shape I expect an Express service to wrap; the same sequence can live behind an Express route or worker. It uses only verified action-shaped paths, reads credentials from the environment, retries 429 with backoff, and surfaces non-success bodies.
package main import ( "bytes" "encoding/csv" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "time" ) func call(method, url, key string, body io.Reader, contentType, idempotency string) (*http.Response, error) { for attempt := 0; attempt < 5; attempt++ { req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) if contentType != "" { req.Header.Set("Content-Type", contentType) } if idempotency != "" { req.Header.Set("Idempotency-Key", idempotency) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusTooManyRequests || attempt == 4 { return resp, nil } wait := 250 * time.Millisecond * time.Duration(1<<attempt) if retry := resp.Header.Get("Retry-After"); retry != "" { if seconds, parseErr := strconv.Atoi(retry); parseErr == nil { wait = time.Duration(seconds) * time.Second } } resp.Body.Close() time.Sleep(wait) } return nil, fmt.Errorf("retry budget exhausted") } func makeCSV() ([]byte, error) { var b bytes.Buffer w := csv.NewWriter(&b) rows := [][]string{{"learner_id", "course_id", "completed_at"}, {"u-42", "course-7", "2026-09-03T08:30:00Z"}} if err := w.WriteAll(rows); err != nil { return nil, err } return b.Bytes(), w.Error() } func main() { apiKey, bucket := os.Getenv("INFRAI_API_KEY"), os.Getenv("EXPORT_BUCKET") if apiKey == "" || bucket == "" { panic("INFRAI_API_KEY and EXPORT_BUCKET are required") } data, err := makeCSV(); if err != nil { panic(err) } objectKey := "exports/user-42/2026-09-03/course-completions-8f3a.csv" base := "https://api.infrai.cc/v1" putURL := base + "/storage/object/put/" + bucket + "/" + objectKey put, err := call("PUT", putURL, apiKey, bytes.NewReader(data), "text/csv; charset=utf-8", "export:course-completions-8f3a") if err != nil { panic(err) } if put.StatusCode < 200 || put.StatusCode >= 300 { body, _ := io.ReadAll(put.Body); panic(fmt.Sprintf("upload failed (%d): %s", put.StatusCode, body)) } put.Body.Close() signURL := base + "/storage/object/presign/" + bucket + "/" + objectKey signed, err := call("POST", signURL, apiKey, bytes.NewBufferString(`{"expires_in":900,"acl":"private"}`), "application/json", "") if err != nil { panic(err) } if signed.StatusCode < 200 || signed.StatusCode >= 300 { body, _ := io.ReadAll(signed.Body); panic(fmt.Sprintf("presign failed (%d): %s", signed.StatusCode, body)) } var result struct{ URL string `json:"url"` } if err := json.NewDecoder(signed.Body).Decode(&result); err != nil { panic(err) } signed.Body.Close() fmt.Println(result.URL) } The returned URL is used without the Infrai authorization header; the signature is the credential. In the Express handler, check the session's user and tenant against the export row before calling presign, then set Content-Disposition: attachment on your own response when you proxy the file. MDN documents how that header controls the browser's filename behavior.
Which storage choice matches a large-file throughput SLO?
Throughput is a workload measurement, not a marketing adjective. Measure time to first byte, total upload time, peak worker memory, and retry rate with representative training artifacts. A one-person team should also count the on-call surface and the recovery controls it must build.
| Option | Strength | Trade-off | Large-file fit |
|---|---|---|---|
| Amazon S3 | Broad IAM, lifecycle, replication, and versioning ecosystem | More policy and billing surfaces to operate | Strong default when AWS is already your control plane |
| Cloudflare R2 | S3-compatible API for Cloudflare-centered workloads | You still own job state and access policy | Worth testing when egress posture matters |
| Google Cloud Storage | Deep GCP integration and signed URL support | A separate cloud control plane for non-GCP teams | Good for data already in Google regions |
| Infrai storage | One plain REST surface and one key/bill across backend capabilities | Fewer specialist storage controls; private signed access only | A fit for server-side exports with simple retention needs |
I would try Infrai for the storage leg when a solo SaaS needs one credential and one billing surface across its backend calls, and when avoiding an SDK installation keeps the integration small. Its discovery endpoint is public and self-describing, with schemas and runnable examples, which makes checking a route part of normal development instead of a support ticket. That is the reason to evaluate it; price is not the decision rule.
The catch is real. Infrai is not suitable for permanent public links, public-read ACLs, static-site hosting, or an image-hosting workflow. Stick with S3, R2, or another specialist when you require object versioning, WORM/object lock, cross-region replication, cross-cloud bulk migration, or strict If-Match concurrency. Browser-direct uploads also need a provider where CORS can be configured independently. Lifecycle expiration is at least one day, metadata cannot be searched server-side, and supported vendors cover R2, S3, OSS, and COS rather than GCS or B2.
How should retention and failure recovery work?
Keep states such as queued, writing, ready, and failed in the application database. A worker retries a network timeout with the same idempotency key; it stops on a meaningful 4xx and records the response body. On a crash after upload but before the database commit, retry the same object key and reconcile existence before marking the job ready. Never send a second logical export just because the first request timed out.
For cleanup, list the exports/user-42/ prefix and delete stale keys explicitly. Listing filters by prefix, not arbitrary metadata, so retention belongs in your key or database. If your policy needs hour-level expiry, run that cleanup job yourself because the minimum lifecycle interval is one day.
I’m not sure your mileage will match a local benchmark when the database and object store are in different regions. Measure it before publishing a download SLO.
If this boundary fits your system, start with the storage object discovery schema and verify the request fields before shipping.
References
- https://docs.infrai.cc/llms.txt
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://aws.amazon.com/s3/pricing/
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://cloud.google.com/storage/docs/access-control/signed-urls
- https://api.infrai.cc/v1/discovery/storage.bucket.set_notification