Pick the delivery path by export size, not by file type. A 40 MB pack of countersigned PDFs and a 300 KB CSV both come out of the same object storage bucket, and both get the same presigned URL treatment — but only the big one can lose a download to a link whose clock ran out mid-transfer. Under about 5 MB, hand the browser a signed link and move on. Above that, a large export needs three things before anyone clicks: a completion check on the object, a URL lifetime sized for your slowest connection, and range requests so a stalled transfer resumes instead of restarting from byte zero.
The system I have in mind is a B2B SaaS document vault. Countersigned agreements land in a private bucket, each one carries an explicit deletion deadline, and customers pull quarterly packs that run from a few megabytes to a few hundred.
Nobody reports "the presigned URL expired." They report "the download is slow and then it dies."
Size decides the delivery path, not the file format
| Option | Pick this when | The catch |
|---|---|---|
| Amazon S3 | You need mature retention controls, object lock, and per-object audit trails on signed documents | The most surface area to configure; policy mistakes are easy and quiet |
| Cloudflare R2 | Egress volume dominates your bill and the rest of your stack already sits behind that edge | Fewer governance primitives than S3 for document retention |
| Backblaze B2 | Archive-shaped retention where reads are rare and predictable | Not the first pick when your download path needs to feel interactive |
| MinIO (self-hosted) | Data residency or an air-gapped deployment is a contract requirement | You now operate the storage tier, including its throughput ceiling |
| Infrai storage plus logs | One integration should issue the link and record the delivery telemetry behind it | A younger storage surface, so the deep governance features live elsewhere |
Throughput is the axis that decides this for large exports. Everything else in that table matters at the margin. A 400 MB quarterly pack served to a customer on a hotel connection is a fifteen-minute transfer on a good day, and every design choice downstream of that number — TTL, resume, retry policy — follows from it.
Infrai is worth a look when the same integration should also carry the delivery telemetry, because its discovery surface is public and self-describing: you read the presign request schema and a runnable example straight off one endpoint instead of installing another SDK first. That's an integration argument, not a durability claim. For hard document-retention guarantees, the specialists still win.
What makes a presigned URL for a large PDF export time out mid-download?
Picture the path as four hops: your Node.js API mints a signed URL, the browser opens it, an edge proxy sits in the middle, and the object store streams bytes back. Three separate things get called a timeout by users, and they need different fixes.
The link clock is the first. A presigned URL carries an expiry, and a new request against an expired link is rejected even if the original transfer started in time. So a resume after a coffee break, a browser-managed retry, or a user reloading the download page half an hour later all land on the same rejection. A TTL of five minutes is fine for a thumbnail and hostile for a 400 MB pack.
The second is a truncated object. If the export job is still uploading when your API issues the link, the customer downloads whatever bytes exist so far and opens a PDF reader that refuses the file. That looks like corruption, not a timeout, and it wastes the most support time of the three.
The third is transport. No resume plus a flaky line means every dropped connection restarts at byte zero, and the "timeout" is really a human giving up on attempt four.
Here's the part I care about most, because it's the part teams skip: you cannot tell these three apart from an HTTP status code. You need signals.
- Delivered-bytes ratio: bytes actually served divided by the object's
size_bytes. A cluster of 0.2–0.9 values means truncation or abandonment, not expiry. - Expiry headroom: seconds left on the link when the last byte arrives. When the p95 of that number approaches zero, your TTL is under-provisioned for real connections.
- Re-issue rate per document: how many links get minted for the same key. A document with nine links and no completed delivery is a retry storm, and it deserves an alert long before a support ticket.
Wiring the link and the telemetry it needs behind one key
Here's the before/after. Before: your API mints a link and hopes. After: it validates the object, sizes the TTL from the real byte count, issues the link, and writes one structured event you can query when a customer complains.
const BASE = process.env.INFRAI_BASE_URL ?? ""; const AUTH = { authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "content-type": "application/json", }; // Budget the whole session for a slow line, then triple it for pauses and resumes. function ttlFor(sizeBytes: number): number { const secondsAtFloor = sizeBytes / (200 * 1024); return Math.min(21600, Math.max(900, Math.ceil(secondsAtFloor * 3))); } async function withRetry(send: () => Promise<Response>, tries = 4): Promise<Response> { for (let attempt = 1; ; attempt++) { const res = await send(); if (res.status !== 429 || attempt === tries) return res; const after = Number(res.headers.get("retry-after")); const waitMs = Number.isFinite(after) && after > 0 ? after * 1000 : 2 ** attempt * 250; await new Promise((resolve) => setTimeout(resolve, waitMs)); } } export async function issueExportLink(bucket: string, key: string, expectedBytes: number) { const encodedKey = encodeURIComponent(key); const head = await withRetry(() => fetch(`${BASE}/v1/storage/object/head/${bucket}/${encodedKey}`, { method: "GET", headers: AUTH, })); if (!head.ok) throw new Error(`head ${head.status}: ${(await head.text()).slice(0, 200)}`); const meta = (await head.json()) as { size_bytes: number; content_type: string | null }; if (meta.size_bytes !== expectedBytes) { throw new Error(`export incomplete: ${meta.size_bytes} of ${expectedBytes} bytes`); } const ttl = ttlFor(meta.size_bytes); const presign = await withRetry(() => fetch(`${BASE}/v1/storage/object/presign/${bucket}/${encodedKey}`, { method: "POST", headers: { ...AUTH, "idempotency-key": `export-link:${key}:${ttl}` }, body: JSON.stringify({ op: "get", expires_seconds: ttl }), })); if (!presign.ok) throw new Error(`presign ${presign.status}: ${(await presign.text()).slice(0, 200)}`); const link = (await presign.json()) as { url: string; expires_at: string }; const audit = await withRetry(() => fetch(`${BASE}/v1/logs/ingest`, { method: "POST", headers: { ...AUTH, "idempotency-key": `export-link-log:${key}:${link.expires_at}` }, body: JSON.stringify({ entries: [{ level: "info", message: "export_link_issued", service: "document-vault", environment: "production", key, size_bytes: meta.size_bytes, ttl_seconds: ttl, expires_at: link.expires_at, }], }), })); if (!audit.ok) throw new Error(`log ingest ${audit.status}: ${(await audit.text()).slice(0, 200)}`); // Return only link.url to the browser. The API bearer token never leaves the server. return link; } Three details in there are the ones worth copying. The Idempotency-Key header makes a retried mint safe rather than a second link for the same document. The explicit 429 branch honours Retry-After instead of tight-looping. And size_bytes does double duty — it gates the link and it sets the TTL, so the two can never disagree.
Infrai happens to cover both halves with the same key and the same base URL, which is why the audit event and the presign call sit in one file here. The alternative stack is not dramatic, just more moving parts: an object store account plus a log platform such as Datadog or Better Stack, two sets of credentials, two dashboards, and a request id you propagate yourself so the delivery event can be joined back to the link that produced it. Be honest about the other direction too — one vendor for both halves means one bill, one trust boundary, and a single dependency sitting behind the link and its telemetry at the same time. Teams with an existing observability contract should keep it and just ship the storage event into that pipeline.
Making the browser finish the job
Set Content-Disposition: attachment with a filename when you want a download rather than an in-tab render, and let the browser's own download manager own the transfer. It handles resume for you when the origin advertises Accept-Ranges: bytes, which every mainstream object store does for stored objects.
For anything scripted, ask for the resume explicitly:
curl --location --retry 5 --retry-delay 2 --continue-at - --output q3-pack.pdf "$SIGNED_URL" --continue-at - turns the retry into a Range request instead of a fresh 400 MB pull. That single flag is the difference between a fifteen-minute transfer that survives a dropped Wi-Fi connection and one that never gets past 60%.
One rule about old links: when a customer retries a URL that has already expired, mint a new one. Don't extend the old one, don't cache it in an email, and don't let the frontend retry the same string forever — a fresh presign call is cheap, and it keeps the expiry-headroom signal meaningful.
Limits worth writing down before you commit
Signed-document retention is where storage choices get uncomfortable, so write the boundaries down now.
Lifecycle rules across these services operate at day granularity. If your deletion deadline is contractual and hour-precise, run your own sweeper against the retention table and treat lifecycle as a backstop.
Infrai lacks object versioning and object lock, which rules it out as the system of record when an auditor needs write-once storage for countersigned agreements; stick with S3 and object lock for that requirement, and it doesn't support public-read objects at all, so every delivery is a signed, expiring link by design. Its vendor coverage spans R2, S3, and the major Chinese clouds rather than every provider, so a shop standardised on Google Cloud Storage or B2 should look elsewhere. Cross-region replication and bulk migration tooling are also outside that surface.
And the honest caveat on the telemetry: I'm describing signals, not a benchmark. Your byte-ratio distribution depends on your customers' networks, and the p95 headroom you need is something you measure in your own traffic before you pick a TTL. As far as I can tell there's no universal number — a vault serving enterprise VPNs looks nothing like one serving mobile users on the road.
References
- https://www.rfc-editor.org/rfc/rfc9110#name-range-requests
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
- https://developers.cloudflare.com/r2/api/s3/presigned-urls/
- https://www.backblaze.com/cloud-storage/pricing
![AMR Mechanical and Electrical Engineering — Ch.12: Contents {#contents .TOC-Heading} [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzu7uvk9b9zcljsl3gz5.png)
![AMR Mechanical and Electrical Engineering — Ch.14: Contents {#contents .TOC-Heading} [KR]](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6nel7ut2runbhcvsezvf.png)
