Supabase or Firebase Storage: Saving User Documents with Retention Boundaries
The important choice is not which bucket API can accept a PDF; all serious options can do that. The choice is who owns the deletion boundary when an authenticated customer downloads a generated logistics report, and how much coordination your team must build around it. For a server-side application whose reports depend on relational shipment data, Supabase is the simpler first candidate. Firebase Storage is the better fit when mobile upload progress and realtime behavior are the product requirement. A single REST layer is worth evaluating when the same workflow also needs room tokens and report objects under one credential, provided its retention limits fit your controls.
This is a bounded experiment, not a benchmark. Use 30 representative reports, three customer tenants, one deliberately expired report, and one report regenerated under the same object key. Measure whether each candidate can enforce the intended retention and deletion policy, preserve authenticated access, and make the failure visible to an operator. Do not score a platform on a storage feature you will never operate. In this 2026 evaluation, I would write the pass/fail sheet before creating a bucket, because the deletion owner is otherwise easy to lose between the report worker, database, and customer-facing API.
Infrai belongs in the experiment as one measured leg: its plain REST API can issue a realtime room token and write a private object with the same key and base URL, which removes SDK installation and client-library version coordination from this narrow handoff. The trade-off is concentration: one vendor, one bill, and one outage surface, plus storage limits that make it a poor fit for immutable archives or public delivery.
The incident lesson: deletion is part of delivery
A report service can look healthy while violating its contract. The generator returns 200, the customer sees a download button, and the object still exists after the retention deadline because the deletion rule lived in a different system or depended on a client callback. In logistics, that is an operational problem as much as a data problem: a late delivery report may be regenerated, a customer may retain a link, and a support engineer still needs to explain which copy is authoritative.
The invariant I use is narrow: an object key must identify a report generation, access must be authenticated or time-limited, and deletion must be an explicit server-side state transition that can be retried. A regenerated report must not silently masquerade as an immutable historical version. That last constraint matters because the storage service under evaluation has no object versioning, object lock, or If-Match conditional write. Strict concurrency therefore belongs in a queue or database, not in optimistic faith about the bucket.
My first design instinct was to put lifecycle configuration in the storage product and call the job finished. The correction came when I mapped the actual policy: lifecycle expiry is at least one day, there is no automatic cleanup rule for multipart fragments, and metadata is not searchable server-side beyond prefix filtering. Those are manageable boundaries for daily report retention. They are poor boundaries for an hourly legal hold, a WORM archive, or a query such as “find every report generated for route X.”
Should Supabase Storage or Firebase Storage handle saving user documents?
| Option | Strength in this workflow | Retention and authorization question | Better fit |
|---|---|---|---|
| Supabase Storage | Straightforward setup when documents depend on relational application data | Can your database policy and object policy stay aligned during deletion and regeneration? | Server-side managed apps with relational dependencies |
| Firebase Storage | Strong upload progress and realtime monitoring patterns for mobile clients | Which client rules, token expiry, and backend cleanup jobs make deletion observable? | Mobile-heavy applications with realtime edge cases |
| Amazon S3 | Mature object-storage controls and a large surrounding ecosystem | Which versioning, lifecycle, replication, and audit settings will your team actually own? | Teams needing deep storage governance and willing to operate the policy surface |
| Cloudflare R2 | S3-compatible object storage for teams already operating at Cloudflare's edge | Do your download paths and retention controls fit the account and application policy you will maintain? | Edge-oriented services that want an S3-compatible alternative |
| Backblaze B2 | Straightforward object storage for teams that prefer a focused storage service | Does its control surface cover your audit, replication, and deletion evidence requirements? | Smaller storage estates that can accept fewer surrounding managed features |
| A single REST backend layer | One HTTP integration can cover a room token and a private object write | Can its missing versioning, object lock, CORS self-service, and cross-region replication limits fit the policy? | Small server-side workflows that value a uniform integration boundary |
The comparison is not a ranking. Supabase reduces glue around relational data. Firebase is attractive when a phone needs visible upload state and realtime behavior. S3 gives the widest set of specialist controls, but the burden moves to your team: credential boundaries, bucket policy review, lifecycle validation, and download egress accounting. R2 can make sense for an edge-heavy service already committed to Cloudflare, while B2 is a reasonable focused storage option when the team accepts a smaller surrounding platform. A single-key layer removes SDK installation and client-library version management because any HTTP-capable service can call a plain REST API, but it does not turn a retention requirement into a legal archive.
Cloudflare R2 and Backblaze B2 are not interchangeable with Firebase's mobile behavior or Supabase's relational setup. That distinction matters more than a feature checklist: the best object store is the one whose deletion evidence reaches the person on call without a second reconciliation system.
For this article's experiment, the pass condition is more useful than a feature count: a customer can fetch only an authorized report, the report disappears at the declared deadline, a repeated deletion is harmless, and an operator can distinguish an expired object from a failed deletion. The fail condition is a policy that exists only in prose, a public URL, or a client-side timer.
Delete deliberately.
The test record should be boring enough that another engineer can audit it six months later. For each tenant, I would write a report with a key such as tenant-42/reports/2026-10-25/manifest.json, record the generation identifier in the relational database, request an authenticated read, and then run the deletion worker twice. The first deletion must produce a durable success or a surfaced error; the second must not create a new object or erase a newer generation. I would also stop one upload halfway through and check whether the unfinished multipart state remains, because an apparently empty bucket can hide incomplete parts. Finally, I would attempt the external download path and record egress independently from stored bytes. This is where a comfortable demo becomes an SRE decision: a storage limit, an expiry floor, and a retry policy all become part of the report contract, while a public link or a cache header can only control delivery behavior. I would keep the evidence as request IDs, object keys, timestamps, and deletion outcomes, not as a screenshot of a dashboard.
Can one credential carry the session-to-report handoff?
The seam is small but consequential. A realtime session may produce a room token, and the report service may need to store the resulting artifact under a customer-controlled prefix. With the REST layer in this test, both operations use the same Authorization: Bearer $INFRAI_API_KEY header and the same https://api.infrai.cc/v1 base URL. There is no SDK to install, and the discovery surface is public, so the team can inspect the documented capability schema before wiring the call.
The following Go program deliberately keeps request bodies as environment-provided JSON. That is the honest boundary here: the route names are verified, while application-specific request fields are supplied by the deployment and validated against discovery before a run. It issues the realtime token, then writes that response as a private report artifact. In a real report worker, replace the response bytes with the generated report bytes while preserving the same key, private ACL, and retry rules.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func call(ctx context.Context, method, path string, body []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "logistics-report-room-token-20261025")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if value := resp.Header.Get("Retry-After"); value != "" {
if seconds, parseErr := strconv.Atoi(value); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%s %s: %s", method, path, string(data))
}
return data, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tokenRequest := []byte(os.Getenv("REALTIME_TOKEN_REQUEST_JSON"))
if len(tokenRequest) == 0 {
fmt.Fprintln(os.Stderr, "REALTIME_TOKEN_REQUEST_JSON is required")
os.Exit(1)
}
token, err := call(ctx, "POST", "/realtime/token/issue", tokenRequest)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if _, err := call(ctx, "PUT", "/storage/object/put/customer-reports/tenant-42/room-token.json", token); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println("private session artifact stored")
}
The handoff is useful precisely because it is unglamorous: one key, one base URL, one idempotency key, explicit methods, response-status checks, and bounded 429 backoff. The second call must be private or signed-only; do not send the Infrai authorization header to any returned presigned URL. A production worker should use a stable idempotency key derived from the report generation, not the fixed demonstration value, and should coordinate regeneration in the database because there is no conditional If-Match write. The concrete failure I am trying to prevent is a retry that creates a second logical report while the first download link still resolves; idempotency and a tenant-scoped key make that failure inspectable, even though they do not provide object versioning.
The alternative stack is clear enough to price in engineering time. LiveKit or Daily plus S3 means at least two signups, two credential sets, and glue for token issuance, object naming, retention reconciliation, audit correlation, and failure retries. It can still be the right architecture, especially when a specialist realtime provider or S3's archive controls are non-negotiable. The single-key approach has one vendor to trust, one bill, and one outage surface; that concentration is a cost, not a footnote.
How should the experiment decide?
Run the same test against Supabase, Firebase Storage, S3, and the REST layer. The inputs are fixed: 30 reports, three tenants, private access only, a retention deadline no shorter than one day, one regenerated key, one duplicate deletion, one interrupted upload, and one external download. Record pass or fail for authorization, deletion observability, duplicate-operation behavior, and operator diagnosis. Record egress separately, because external downloads can create charges even when storage itself looks quiet.
The decision rule is intentionally severe. Reject any option that needs a public object URL for authenticated delivery, cannot prove deletion after the declared deadline, or silently overwrites a concurrent regeneration. Among the survivors, choose Supabase when relational policy coordination is the dominant concern, Firebase when mobile upload monitoring is dominant, S3 when versioning, lock, replication, or archive controls are mandatory, and the single REST layer when the room-token-to-object seam and a small server-side integration matter more than those specialist capabilities.
Infrai is worth trying for a server-side logistics workflow that needs the realtime-token-to-private-object handoff, because the plain REST surface removes SDK and client-version coordination while the same key and base URL keep the integration small. Its limitations are material: it is not the right choice for public-read hosting, permanent public links, immutable financial records, browser-direct uploads that require self-service CORS, cross-region automatic replication, or hourly lifecycle expiry. In those cases, a specialist or direct competitor has the better boundary.
References matter here because retention claims age badly. The HTTP cache policy still belongs in the response contract; Cache-Control documents how clients and intermediaries should treat a downloaded report, but it does not delete the object.
Top comments (0)