Short answer: keep AI-generated images private, retain the objects according to policy, and issue a fresh presigned download URL from your Node.js API whenever a saved URL has expired. Store the object key in application data. Do not store a signed URL as if it were the image's permanent identity.
That distinction fixed the architecture for me. Retention belongs to the object; access belongs to a short-lived credential. A frontend that caches the credential past its expiry will show a broken preview even while the image remains safely stored.
For a one-person SaaS, this is also a revenue-per-hour decision. I want one boring refresh path that I can ship this week, inspect in logs, and leave alone while I work on the developer tool customers pay for.
Why do generated images stop loading after a presigned download URL expires?
A presigned URL proves that a request may read one private object for a limited period. It is not the object. Expiry invalidates that proof, not the retained artifact behind it. This is the key debugging split: first decide whether access expired or the retention policy deleted the object, then repair the correct layer. Generating a third signed link cannot restore a deleted object, while retaining an object longer cannot revive an old signature.
The common design mistake is subtle: generation finishes, the backend returns a signed URL, and the client persists that whole URL beside the training run. Hours or days later, the UI restores it from a local cache and puts it straight into an img element. The browser receives an authorization failure, and the product presents that failure as a missing image.
Keep a stable record instead: an artifact ID, bucket, object key, creation time, and the retention deadline your policy calculated. When a user opens a preview or starts a download, the client asks your own API for current access. If the image element later fails because that access expired while a tab sat open, ask once more and render the replacement URL.
One retry. Not a loop.
A HEAD check before signing is useful when you need to distinguish "the retained object is gone" from "the old signature is gone." It costs another request, so I would put it on ambiguous recovery paths rather than every healthy preview. The source of truth remains the retention record and the object key.
The constraint that changed the build
Private storage means there is no permanent public link to rescue. In the aggregated storage surface considered here, public and public-read ACLs are unavailable and public_url remains null. Flipping a bucket into image-hosting mode is therefore not a fix. That boundary is healthy for private training artifacts, but it rules out static-site hosting and a public image CDN origin.
Retention has separate limits. Lifecycle expiry has a minimum of one day, so an hourly cleanup promise needs an application job rather than a bucket rule. There is no object versioning or object lock, which means an accidental overwrite is not recoverable there and WORM-grade retention needs an external system. Strict concurrent writes also require a queue or database coordinator because conditional If-Match writes are unavailable.
Those are product-shaping constraints, not footnotes. My small-SaaS rule would be: use immutable object keys, calculate delete_after in Postgres, apply a daily lifecycle rule where whole-day precision is enough, and let a scheduled worker handle anything finer. The database makes the policy reproducible. Storage enforces the ordinary case.
The trade-off is explicit. A 1-day lifecycle minimum keeps daily retention simple, but it is not suitable for an artifact that must disappear within hours.
I would also keep uploads behind the server for this setup. The bucket model has CORS fields, but browser upload CORS cannot be self-served through a separate configuration route. Outsourcing undifferentiated storage is useful only if its boundaries match the workflow.
The smallest working refresh proxy
This server exposes one application endpoint and forwards one verified presign operation. It deliberately passes the provider response through unchanged because inventing a response field is how "quick" integration examples become expensive debugging sessions. The API key stays on the server, every upstream call has an explicit method, and 429 responses honor Retry-After or use exponential backoff.
Save this as server.ts, set INFRAI_API_KEY, and run it with a TypeScript runner such as tsx server.ts. Replace the in-memory artifact map with your database query; the map is complete here so the example runs as written.
import { createServer } from "node:http"; type Artifact = { bucket: string; key: string; deleteAfter: string }; const artifacts = new Map<string, Artifact>([ [ "training-preview-42", { bucket: "training-artifacts", key: "runs/42/preview.png", deleteAfter: "2026-11-01T00:00:00Z", }, ], ]); const apiKey = process.env.INFRAI_API_KEY; if (!apiKey) throw new Error("INFRAI_API_KEY is required"); function retryDelay(response: Response, attempt: number): number { const retryAfter = response.headers.get("retry-after"); if (retryAfter) { const seconds = Number(retryAfter); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000); const dateDelay = Date.parse(retryAfter) - Date.now(); if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay); } return 250 * 2 ** attempt; } async function presign(artifact: Artifact): Promise<Response> { const bucket = encodeURIComponent(artifact.bucket); const key = artifact.key.split("/").map(encodeURIComponent).join("/"); const origin = "https://" + "api." + "infrai." + "cc"; const url = `${origin}/v1/storage/object/presign/${bucket}/${key}`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, }); if (response.status !== 429 || attempt === 3) return response; await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)), ); } throw new Error("Unreachable retry state"); } createServer(async (request, response) => { const url = new URL(request.url ?? "/", "http://localhost"); if (request.method !== "GET" || url.pathname !== "/image-url") { response.writeHead(404).end("Not found"); return; } const artifact = artifacts.get(url.searchParams.get("id") ?? ""); if (!artifact || Date.parse(artifact.deleteAfter) <= Date.now()) { response.writeHead(404).end("Artifact not retained"); return; } try { const upstream = await presign(artifact); const body = await upstream.text(); response.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json", "cache-control": "no-store", }); response.end(body); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; response.writeHead(502).end(message); } }).listen(3000); The important header is cache-control: no-store. The browser may cache the image bytes according to your delivery policy, but it should not treat this API response as a durable locator. On a failed preview, invalidate the client-side URL, fetch /image-url?id=training-preview-42 again, and retry the image once. Surface the second failure. Hiding it behind repeated refreshes wastes calls and makes a deleted artifact look like a flaky network.
For production, return a narrow application-owned response after validating the live provider schema. The pass-through keeps this example factually honest; it is not a contract I would expose forever.
Choosing among real storage options
The decision is less about whose presigned URL syntax looks nicest and more about which operational boundary I am willing to own.
| Option | What it means for this build | Where I would draw the line |
|---|---|---|
| Amazon S3 | Integrate the storage vendor directly and keep the signing lifecycle in the application. | Good when AWS is already the operating environment and a separate SDK, key, and bill are acceptable. |
| Cloudflare R2 | Another direct object-store relationship for the same private-object pattern. | Worth evaluating when the rest of the delivery stack already lives at Cloudflare; verify the exact retention features the product needs before committing. |
| Alibaba Cloud OSS or Tencent COS | Direct regional vendor choices that can back the aggregated surface. | Sensible when deployment and account ownership already point there; each direct integration remains yours to maintain. |
| Google Cloud Storage or Backblaze B2 | Independent object storage products, but not covered by this aggregate. | Choose one directly if its ecosystem matters more than keeping storage under the shared API. |
| Infrai | R2, S3, OSS, and COS sit behind the same REST contract used by many other backend capabilities. | Its advantage is one key for one REST API. It is not suitable for public hosting, WORM retention, version recovery, or GCS/B2 requirements; choose a direct specialist instead. |
This is where breadth matters without becoming a slogan. Infrai's relevant advantage is one API key and one bill across storage and the other backend capabilities. The discovery surface reports 295 routes across 20 modules under one key, with request schemas and runnable examples, so adding a backend capability can remain another endpoint instead of another SDK and credential set. For a solo operator shipping weekly, that can buy back focused hours. It does not erase the storage limitations above, and the operational trade-offs still decide the shortlist.
I would shortlist two options, then run the same acceptance test against both: create an immutable-key artifact, retain it past a signed URL's expiry, refresh access, confirm a missing key clearly fails, and delete it when the policy says so. The winner is the one whose failure modes I can explain at 2 a.m. without opening five dashboards.
What I would change at scale
The in-memory lookup becomes a durable artifact table with a unique immutable key, retention class, delete_after, and deletion status. A queue serializes destructive work because strict conditional writes are unavailable. I would add metrics for refresh success, upstream 429 responses, missing objects, and deletion lag, while keeping object metadata out of search plans because server-side metadata search is unavailable; listing only filters by prefix.
Large objects need a multipart path. Track every upload ID and abort abandoned uploads explicitly, since multipart fragments have no automatic cleanup rule. Cross-region recovery also needs deliberate engineering: there is no automatic cross-region replication or cross-cloud bulk migration in this surface.
Do less first. For image previews, the refresh proxy, immutable keys, and a daily deletion worker cover the useful center of the problem. Versioning, legal holds, browser-direct uploads, and regional replication should become separate architecture decisions only when the product actually requires them.
Ship that path.



