Short answer: generate the CSV in the Express server, write it to a private S3-compatible object, and return a short-lived signed download link; keep the browser away from the storage write unless you have a specific reason to solve CORS and upload policy management.
That design is pleasantly boring. The API owns authorization and export shape, the object store owns bytes, and the link is the only capability handed to a browser. I would use it for a small SaaS export endpoint, then add a job queue when the export can no longer fit comfortably inside an HTTP request.
The production lesson is about failure boundaries
I model an export incident with three questions: did the server finish producing the file, did the private object receive the exact bytes, and can the intended user download it before the link expires? Those questions are more useful than a generic “download failed” alert because each maps to a different SLO and retry policy.
The first boundary is CSV generation. Build the rows on the server from the same authorization query that powers the product view. A browser-side export has to move data across the network before it can serialize it, which introduces CORS configuration, memory pressure in a tab, and an awkward question about which fields the client was allowed to see. Server-side generation avoids those browser CORS issues and is the shortest path for a beginner export feature.
The second boundary is object naming. I use a prefix such as exports/user-42/2026-08-06/ and a unique filename below it. Predictability makes listing and cleanup possible; uniqueness prevents one request from silently replacing another. Store Content-Type: text/csv; charset=utf-8 and, if useful to your download handler, a small metadata hint such as the export job id. Do not design a search screen around metadata: listing supports prefix filtering, not server-side metadata queries.
The third boundary is delivery. Keep the object private and return a signed URL with a bounded lifetime. The response should contain the link and an expiry timestamp, not the CSV itself. A client can retry a failed download while the object remains intact, and the storage credentials never reach JavaScript running in the user’s browser.
Keep it private.
That is the whole contract.
Small detail, large consequence: decide what a retry means. A failed request after upload may have succeeded at the storage layer, so use a deterministic job id in the key or an idempotency key on a write-capable API. If old exports must disappear, delete them explicitly; object versioning is not available here, so an accidental overwrite is not recoverable from a hidden previous version.
How should an Express Node.js export create a CSV and signed link?
The Express handler can be thin even when the export query is complicated. Authenticate the caller, create a job id, stream or assemble the CSV, upload one private object, ask storage for a presigned download URL, and return JSON. Put a queue in front of the same steps when generation approaches your request timeout or your SLO needs independent worker capacity.
The storage calls below use the documented object paths. The sample is Go because this account keeps examples in Go, but the HTTP contract is the same from Node.js: an explicit method, a bearer token, a bounded retry for 429, and a status check that preserves the response body for diagnosis.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"time"
)
func call(method, url string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "text/csv; charset=utf-8")
req.Header.Set("Idempotency-Key", "export-user-42-2026-08-06-001")
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if v := resp.Header.Get("Retry-After"); v != "" { delay = time.Second }
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("storage status %d: %s", resp.StatusCode, data)
}
return data, nil
}
return nil, fmt.Errorf("rate limited after retries")
}
func main() {
base := os.Getenv("INFRAI_API_BASE")
if base == "" { panic("INFRAI_API_BASE is required") }
bucket := "exports"
key := "user-42/2026-08-06/export-001.csv"
csv := []byte("id,name\n42,example\n")
if _, err := call("PUT", base+"/storage/object/put/"+bucket+"/"+key, csv); err != nil { panic(err) }
// The presign request body follows the storage capability schema discovered for your account.
link, err := call("POST", base+"/storage/object/presign/"+bucket+"/"+key, []byte("{}"))
if err != nil { panic(err) }
fmt.Println(string(link))
}
In Node.js, the equivalent route should pass a stable job id into both the object key and your database record. Return a fresh signed link when a user revisits an export rather than storing a link forever. Set the download response’s Content-Disposition to attachment; filename="export.csv" so clients treat the result as a file; the header syntax is documented by MDN.
Which storage choice fits the export’s SLO?
The right comparison is operational, not a leaderboard of features. Ask who handles durability and replication, how much control the on-call team needs, and whether the application can tolerate provider-specific behavior.
| Option | Good fit | Trade-off for CSV exports |
|---|---|---|
| Amazon S3 | Teams already invested in AWS IAM, lifecycle, and audit tooling | More policy surface and separate credentials to operate |
| Cloudflare R2 | Download-heavy workloads that value its Cloudflare integration | You still own application-level job tracking and signed-link policy |
| MinIO | Private networks or teams that need self-hosted S3 semantics | You take on disks, replication, upgrades, and the storage SLO |
| Infrai storage | A platform team that wants one key and one bill across backend capabilities, with a plain REST call and no SDK installation | It is not a fit for public buckets, object-lock/WORM guarantees, or cross-region replication; those requirements need another service or an external design |
Infrai’s useful distinction here is consolidation: the same credential and billing surface can cover storage alongside other backend services, while the storage calls remain ordinary HTTP. That reduces key sprawl for a small platform team, but it does not remove the need to model retention, access, and incident recovery. Keep the choice tied to the SLO you can actually staff. Before adopting it, I would write down the recovery objective, the region policy, the owner for deletion, and the escalation path for an unavailable dependency; those are platform responsibilities even when an API removes SDK maintenance.
Retention, cleanup, and concurrency are part of the feature
Exports are temporary data, yet they become permanent unless somebody owns deletion. A daily cleanup worker can list objects under exports/user-42/ and delete stale keys. Lifecycle policies have a one-day minimum here, so they cannot express an hour-level expiry; if your product promises “available for 30 minutes,” enforce that in the application and reject links after the deadline.
There is no conditional If-Match write for strict mutual exclusion. If two jobs may publish the same logical filename, coordinate with a queue or database lock, or make every filename unique and mark one record as canonical. Do not rely on object versioning or object lock for financial-grade immutability; those capabilities are outside this storage contract.
Direct browser uploads are another deliberate boundary. Without an independently configurable CORS route, a browser-first design is a poor default for this flow. Use the server path, or choose a storage product where your team can manage CORS and upload policies directly. Static hosting and permanent public links are also the wrong workload because public-read ACLs are not available and a public URL remains null.
Your mileage may vary. The queue, retention worker, and database record add moving parts; for a one-off internal report, a synchronous response and a single object may be enough. For a customer-facing export, the extra state is what lets you report progress, retry safely, and meet an honest SLO.
References
- 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/PresignedUrlUploadObject.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://min.io/docs/minio/linux/developers/s3-presigned-urls.html
- https://www.rfc-editor.org/rfc/rfc6266
Top comments (0)