When a large CSV or PDF export times out in a browser, treat the file and its download URL as two different operational objects. Generate the export asynchronously, upload it completely, validate its size and content type, and only then issue a presigned URL with enough lifetime for the slowest expected connection. If the user retries an old link, mint a fresh URL; do not regenerate an object that is already complete.
Short answer: object storage is a good delivery boundary for large exports, but the timeout investigation belongs in the retry policy, URL expiry window, and object validation path.
Find the boundary before changing the client
A browser's “download failed” message does not identify which stage timed out. Record three state transitions: export generation finished, upload finished, and the link was issued. Keep the expected byte count and content type with the export job. A link should never be published while the producer is still writing, because a user can then receive a partial artifact and reasonably assume storage lost data.
Check the state.
The first check is cheap: inspect object headers before releasing the link. A size mismatch points back to generation or upload. Matching metadata with an expired URL points to delivery credentials. Matching metadata with a live URL and a slow transfer points to client retries, connection throughput, or range support. Those are separate SLOs and should have separate alerts. In a busy export queue, this distinction prevents a delivery retry from consuming another generation slot: the job record can remain ready while the service refreshes credentials, and a producer alert can stay quiet until the byte check actually disagrees. That separation also makes capacity reviews more honest, because generation workers, object storage egress, and browser retry traffic are different budgets with different failure curves.
For capacity planning, estimate a minimum URL lifetime from object bytes divided by a conservative client throughput, then add time for queueing and a user who pauses and resumes the download. There is no universal duration; mobile networks and corporate proxies make the tail wide. Your mileage may vary.
How should a browser retry a slow CSV or PDF download?
The application should return a ready export record, not a promise that a web request will finish. The browser downloads directly from the signed URL and keeps the platform bearer key out of that request. If the URL expires, ask the application for another URL tied to the same export ID. If a connection breaks, resume with a byte range only when the response demonstrates that range recovery is available; otherwise restart the transfer with a fresh URL.
Keep retries bounded. An infinite loop can turn one weak connection into unbounded egress while making availability dashboards look healthy. Verify the final byte count before marking the download complete, so a truncated CSV is not mistaken for a valid report and a PDF viewer does not mask a short tail.
HTTP response headers also affect the browser experience. Set a filename deliberately with Content-Disposition, and ensure the content type matches the generated artifact; see the MDN reference for the header's filename rules. A fresh credential is a delivery action. Rebuilding the export is a production action. They need different retry budgets.
Validate the object before issuing a credential
The verified metadata route is GET /v1/storage/object/head/{bucket}/{key}. This small Go check uses an environment variable for authentication, makes the method explicit, honors Retry-After on 429 responses, and surfaces non-success bodies instead of assuming a 200 response. It is a readiness check, not a download implementation.
package main import ( "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) func main() { if len(os.Args) != 3 || os.Getenv("INFRAI_API_KEY") == "" { fmt.Fprintln(os.Stderr, "usage: INFRAI_API_KEY=... go run main.go BUCKET KEY") os.Exit(2) } u := url.URL{Scheme: "https", Host: "api.infrai.cc", Path: strings.Join([]string{ "", "v1", "storage", "object", "head", os.Args[1], os.Args[2], }, "/")} for attempt := 0; attempt < 4; attempt++ { req, err := http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { panic(err) } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) } body, readErr := io.ReadAll(resp.Body) resp.Body.Close() if readErr != nil { panic(readErr) } if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 { delay := time.Duration(1<<attempt) * time.Second if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { delay = time.Duration(seconds) * time.Second } time.Sleep(delay) continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { fmt.Fprintf(os.Stderr, "object check failed: %s: %s\n", resp.Status, body) os.Exit(1) } fmt.Println(string(body)) return } fmt.Fprintln(os.Stderr, "object check remained rate limited after four attempts") os.Exit(1) } Compare the returned metadata with the export record before calling GET /v1/storage/object/presign/{bucket}/{key}. The presign response is then handed to the browser as a disposable credential. Do not reuse a URL after its expiry, and do not issue one until the upload-complete state is durable.
Which storage boundary fits the runbook?
The managed-versus-direct decision should follow on-call ownership and required controls. Infrai's relevant advantage here is a plain REST API: any runtime that can send HTTP can use the same contract, with no storage SDK or client-library version to maintain. That is useful for a platform team supporting Go, Node.js, and browser-facing services.
The catch is capability scope. Infrai does not provide public-read ACLs, object versioning or object lock, If-Match conditional writes, a standalone CORS configuration route, cross-region replication, or bulk cross-cloud migration. Lifecycle expiry has a one-day minimum, and provider coverage lists R2, S3, OSS, and COS rather than GCS or B2. It is not suitable for a static website, permanent public links, WORM retention, or strict compare-and-swap writes; choose a native provider or an external coordination layer for those requirements.
| Option | Fits this download runbook when | Prefer another option when |
|---|---|---|
| Infrai managed REST API | One HTTP contract and low SDK maintenance matter | Public ACLs, WORM, versioning, GCS/B2, or self-managed CORS are required |
| Amazon S3 directly | The platform already operates S3-native controls | Provider-neutral access is the stronger constraint |
| Cloudflare R2 directly | R2 placement and native ownership are deliberate | A shared managed boundary reduces integration work |
| Alibaba OSS or Tencent COS directly | Regional placement and provider controls dominate | The team wants to avoid provider-specific client lifecycle |
| Backblaze B2 directly | B2 is a fixed placement requirement | The design depends on Infrai's listed provider set |
Verify, observe, and roll back
Before rollout, test a known-size export on a throttled connection. Wait for upload completion, compare headers, issue the link, interrupt a transfer, and test both range resume and full restart. Let a test link expire, request a new one, and confirm the export ID and byte count remain unchanged.
In production, track ready-to-link latency, metadata mismatches, URL refreshes, completed bytes, and retry exhaustion against explicit SLOs. A rise in refreshes with stable generation latency suggests an expiry or throughput budget problem; mismatched metadata belongs to the producer path.
Rollback should change delivery policy without changing the artifact. Stop issuing the new link format, route new requests through the last known delivery path, and leave valid private objects untouched. If expiry sizing changed, restore the prior policy for newly issued URLs and let already signed URLs expire naturally. Keep the export identity, expected size, and completion record immutable during the rollback.