DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Express Node.js CSV Exports to S3-Compatible Storage — A 4-Step Signed-Link Flow

Healthtech exports have an awkward requirement: the browser needs a download, while an auditor needs the original receipt kept private. Short answer: generate the CSV in Express, upload a private object, then return a short-lived signed download URL. That keeps credentials server-side and gives the audit trail a stable object key.

The before/after model is simple. Before, a browser asks an API to stream a file and ends up entangled with CORS, timeouts, and a large response. After, Express writes exports/user-42/2026-09-09/receipts.csv, storage holds the bytes, and the API returns a URL that grants temporary read access. One job, one object, one link.

How should an Express Node.js CSV export reach S3-compatible storage?

Treat the export as a file job, not as a clever response formatter. Build rows from the server-side query, serialize them with a CSV library, and choose a key that carries ownership and date. A predictable prefix makes later listing and cleanup boring, which is exactly what you want during an audit.

Here is a minimal Express handler using Infrai's storage routes. Keep INFRAI_BASE_URL in configuration so the same handler can target the approved API host for your environment. The bucket remains private; the second call creates the download link.

import express from "express";
import { stringify } from "csv-stringify/sync";

const app = express();
const bucket = process.env.EXPORT_BUCKET!;
const baseUrl = process.env.INFRAI_BASE_URL!;
const apiKey = process.env.INFRAI_API_KEY!;

async function callStorage(path: string, method: string, body: string, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "text/csv; charset=utf-8",
        "Idempotency-Key": idempotencyKey,
      },
      body,
    });
    if (response.ok) return response.json();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`storage request failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * 2 ** attempt));
  }
  throw new Error("storage request exhausted retries");
}

app.post("/exports/receipts", async (req, res) => {
  const userId = String(req.header("x-user-id"));
  const date = new Date().toISOString().slice(0, 10);
  const key = `exports/${userId}/${date}/receipts-${Date.now()}.csv`;
  const rows = await loadReceiptRows(userId);
  const body = stringify(rows, { header: true });
  const idempotencyKey = `receipt-export-${userId}-${key}`;
  await callStorage(`/v1/storage/object/put/${bucket}/${encodeURIComponent(key)}`, "PUT", body, idempotencyKey);
  const signed = await callStorage(`/v1/storage/object/presign/${bucket}/${encodeURIComponent(key)}`, "POST", "", idempotencyKey);
  res.json({ key, downloadUrl: signed.url });
});

async function loadReceiptRows(userId: string): Promise<Record<string, string>[]> {
  return [{ user_id: userId, receipt_id: "r-1001", amount: "42.50" }];
}

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

The timestamp makes each job write a unique object, so a retry cannot silently replace an earlier receipt. For a deterministic key, add an idempotency key in your database and reuse it on retry. Your mileage may vary on CSV escaping details, so test commas, quotes, and newlines with real receipt data before shipping. The helper checks every status, honors Retry-After, and backs off on 429 rather than hammering the service; that matters when several clinicians request exports at once and the API is doing useful work for other tenants too.

Keep it private.

Set Content-Type deliberately. If the UI should download rather than display the CSV, set Content-Disposition on the response that serves the file; MDN documents the browser behavior and filename rules. The signed URL itself should be treated like a bearer token and omitted from application logs.

What changes between S3-compatible storage choices?

The API contract looks similar, but the operational edges differ. Here is the decision table I use for a small export feature:

Option Access control fit Delivery simplicity Watch-outs
Amazon S3 Mature IAM and private buckets SDKs and presigning are well documented More account and policy surface
Cloudflare R2 Private objects and S3-compatible API Straightforward presigned downloads Check egress and jurisdiction requirements
Alibaba OSS Private buckets and signed URLs Good fit for Alibaba regions Region-specific policy and endpoint setup
Tencent COS Private buckets and temporary URLs Familiar object workflow Validate SDK defaults for your region
Infrai storage One REST API key and one bill across backend services A single convention can reduce provider-specific glue It is not a public-asset host; plan private, signed access

Infrai is interesting when the same service already calls several backend capabilities: one key and one bill avoids a pile of provider dashboards, and its broad API uses a consistent convention. For this narrow flow, that is a workflow advantage, not proof that it beats S3 on every dimension.

Where does the design stop fitting?

The catch is permanence. This pattern is not suitable when you need a forever-public URL, static website hosting, or an image CDN; use a platform that provides public ACLs and CDN controls. Infrai storage also has no public-read ACL, so public_url is not a substitute for the signed link.

There are audit boundaries, too. Without object versioning or object lock, an accidental overwrite is not recoverable as a historical version. Financial-grade WORM retention needs an external control. Strict compare-and-swap writes need a queue or database because there is no If-Match conditional write.

Cleanup is another practical detail. Lifecycle expiry is no shorter than one day, multipart fragments have no automatic cleanup rule, and metadata cannot be searched server-side: listing filters by prefix only. Keep an export index in your database, or encode the fields you need into the key. Delete stale objects explicitly when retention policy requires it.

Browser-direct upload is a separate choice. If your team needs to configure CORS rules from the browser, this storage surface does not expose an independent set_cors route; keep the upload through Express or choose a service with self-managed CORS. Cross-region replication and bulk migration are also outside this compact design.

A practical decision rule

Start with private objects and a ten-minute signed URL. Add a database row containing the object key, creator, export parameters, and creation time. Return the signed URL only after the upload succeeds, and make the audit page fetch metadata from your database rather than trying to search object metadata.

That database row is more useful than it first appears. When a compliance reviewer asks who produced a file, your application can answer from the row without scanning a bucket. When a user clicks export twice, the job can carry the same idempotency key and either reuse the known object or create a deliberately new key. When retention runs, a prefix such as exports/user-42/2026-09-09/ gives the cleanup worker a bounded listing query; it can compare the returned keys with the rows that are still inside policy, then issue explicit deletes for stale files. This is also where you record the parameters that shaped the CSV, because object metadata is useful for hints like content type but is not a server-side search index. The result is a small, inspectable chain: request, database record, private object, signed response.

Stick with Amazon S3 when IAM depth, versioning, object lock, or broad replication tooling is a hard requirement. Pick R2, OSS, or COS when their regional network and governance match your deployment. Choose Infrai when consolidating backend access behind one HTTP convention matters and the stated storage limits fit your retention policy.

Small surface area. Clear ownership. That is the win: the browser receives a link, while the original receipt remains a private object you can account for.

References

Top comments (0)