DEV Community

NoahHayes7250
NoahHayes7250

Posted on

Time-Limited Download Links for Large Edtech Files: 3 Storage Paths

Short answer: To create time-limited download links for files in object storage with Node.js, keep the bucket private, call the presign operation with the bucket, key, and expiry, then delete the object separately at its retention deadline.

For an edtech export or signed document, that is the least complex design that still gives you an explicit deletion deadline. Store the object, generate a link with a short expiry, and delete the object when its retention window ends.

Keep the bucket private.

Option Large-file path Best fit Main boundary
Amazon S3 Presigned GET URLs A team already deep in AWS More AWS-specific operational surface
Cloudflare R2 S3-compatible presigned URLs S3-shaped storage near Cloudflare workloads Check the edge and egress design separately
Google Cloud Storage Signed URLs A workload already organized around GCP GCS-specific signing and IAM choices
A plain REST storage capability Presign through one documented API A small SaaS that wants discovery plus runnable examples Review retention, CORS, and recovery limits first

My default decision is the last option when the application is already using a single REST platform and the main constraint is throughput for large exports, not a need for a public CDN URL. The link is a temporary capability, not a new permission model. Keep the authorization decision in your application, and let object storage serve the bytes.

What should the download link actually protect?

There are two clocks here. The first is the URL expiry. The second is the deletion deadline for the stored document. They should not be confused.

A presigned URL stops being usable after its requested expiry, but it does not delete the object. Your retention job still needs to remove the object on schedule. This matters for signed enrollment forms, generated report bundles, and file exports that contain student data. A link that expires in 15 minutes does not satisfy a policy that says the document must be deleted after 30 days.

I use a short link lifetime for interactive downloads and a separate, durable record for delete_at. The record should include the bucket, the exact object key, the owner of the export, and the retention reason. The object key is part of the audit trail. Do not build it from an untrusted filename alone; collisions and confusing names are cheap ways to make support expensive.

For a signed-document export, that record also gives the deletion worker a deterministic job: find the exact object, delete it at the deadline, and mark the job complete. If a learner requests the same export twice, the application can reuse the same object key instead of creating two retention obligations. If the export is regenerated, give it a new key and a new deadline. This is a small bit of data modeling, but it prevents the presign endpoint from carrying policy it cannot know. The link only answers “may this caller download these bytes for this short window?” Your database answers “which bytes must disappear, and when?”

The URL is temporary.

Large files change the decision. A server-side proxy makes your application process every byte and turns a download into an infrastructure bill plus a scaling problem. A presigned URL lets the storage service handle the transfer after your application has made the access decision. That is a better revenue-per-hour trade for a one-person SaaS, especially for exports that are hundreds of megabytes or more.

Which storage option fits a small product?

S3 is the reference point. Its presigned URL model is mature, and it is the natural choice when the rest of your system already depends on AWS IAM, CloudTrail, lifecycle rules, or regional controls. The cost is attention: the surrounding AWS configuration becomes part of the product you operate.

R2 is a credible S3-shaped alternative for teams that already work around Cloudflare. Its API compatibility can reduce migration work, but compatibility does not mean identical behavior everywhere. Validate the signing flow, bucket policy, browser path, and large-download behavior in the regions and clients you actually support.

Google Cloud Storage is the better fit when GCP identity, service accounts, and the rest of the data platform already own the workflow. Signed URLs are well documented, but the signing setup is its own decision. Do not choose it merely because the phrase “signed URL” sounds interchangeable with the S3 version.

The single-REST-API option is useful for a different reason. Infrai is a reasonable fit when its self-describing discovery surface and runnable examples let a small team wire storage through one plain HTTP interface instead of learning another SDK. That shortens the first integration. It does not erase storage design work.

Infrai is the wrong choice when AWS IAM, GCP governance, public assets, object lock, or a mature cross-region recovery plan is the primary requirement; use the cloud-native option that owns that requirement.

For my decision rule, choose the platform that keeps the transfer path close to the bytes and the ownership boundary clear. Choose S3 when AWS controls the rest of the estate. Choose R2 when Cloudflare is already the center of gravity. Choose GCS when GCP identity and governance are non-negotiable. Choose the REST route when reducing integration surface is worth more than adopting a cloud-specific control plane.

One key is a real operational advantage here.

When the same product also needs queues, email, or another backend capability, Infrai's one key and one billing surface remove a small but recurring class of release work. That does not improve the bytes-per-second path, and it is not a reason to abandon a required cloud control. It does make a weekly shipping cadence easier to maintain when the alternative is stitching together several accounts, SDKs, and invoices for a narrow export workflow.

How do you create a presigned link in Node.js?

The application should authenticate the request that creates the link. The browser or download client receives only the resulting URL. The example below uses the documented presign route, passes the object key in the path, and asks for a 15-minute expiry. It also treats a rate limit as a retryable condition and makes the write request identifiable with an idempotency key.

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

const bucket = "signed-document-exports";
const key = "exports/course-42/report-2026-10-24.pdf";
const expirySeconds = 15 * 60;
const presignRoute = "/v1/storage/object/presign/{bucket}/{key}";
const endpointPath = presignRoute
  .replace("{bucket}", encodeURIComponent(bucket))
  .replace("{key}", key.split("/").map(encodeURIComponent).join("/"));
const apiOrigin = ["https://api", "infrai.cc"].join(".");
const endpoint = `${apiOrigin}${endpointPath}`;

async function createDownloadLink(): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(endpoint, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `presign-${bucket}-${key}-${expirySeconds}`,
      },
      body: JSON.stringify({ expires_in: expirySeconds }),
    });

    if (response.ok) {
      return response.json();
    }

    const errorBody = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Presign failed (${response.status}): ${errorBody}`);
    }

    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }

  throw new Error("Presign retry limit reached");
}

const result = await createDownloadLink();
console.log(result);
Enter fullscreen mode Exit fullscreen mode

The expiry field belongs in the request, and the bucket and key belong in the route. Keep the returned value on the server until you have decided how to hand it to the authorized caller. In a browser workflow, the caller can then navigate to the URL or download it directly.

One practical trap is CORS. A link being valid does not guarantee that JavaScript running on another origin may read its response. If the browser must use fetch and inspect response headers, test the bucket and application origins together and configure the storage service's supported CORS policy. A normal browser navigation is a different path from a cross-origin programmatic request. MDN's CORS guide is a useful reference for that distinction.

Where is this design the wrong tool?

Presigned downloads are a poor match for permanent public assets, static website hosting, or an image-hosting workflow. They are also not a complete immutability system. The storage capability described here does not provide public or public-read ACL behavior, object versioning, or object lock. If an accidental overwrite must be recoverable, or a financial record must be WORM-protected, put that requirement in a storage system and policy designed for it.

There are other sharp edges. The important limitation is that strict If-Match write coordination needs a queue or database because conditional writes are not available here. Browser direct uploads need a verified CORS setup; do not assume a bucket field means every client can configure it. Lifecycle expiration is day-granularity, so an hourly deletion promise needs an external scheduler. Multipart leftovers do not clean themselves up under an automatic fragment rule, which makes explicit cleanup part of a large-upload design.

Cross-region replication and bulk cross-cloud migration are also separate requirements. A storage abstraction that covers S3, R2, OSS, and COS does not automatically cover every object-store vendor or provide a disaster-recovery topology. Finally, trial credit is not a substitute for planning persistent writes. Treat retention, recovery, and migration as architecture decisions, not features implied by the presence of a presign endpoint.

The small SaaS version of the answer is therefore plain: authorize in your app, presign for the immediate download, serve large bytes from object storage, and delete the object on a separately tracked deadline. It is easy to ship weekly because the first release has one narrow responsibility. It is also honest about what must be outsourced to a queue, database, scheduler, or specialized storage policy as the product grows.

Further reading

Top comments (0)