Large-file throughput changes the answer: a customer-support system that processes receipts and retains each original should not force binary traffic through its transactional database merely because the same upload path also accepts small avatars.
Short answer: for a normal small US/EU SaaS, keep private avatar and receipt files in object storage, store the object key plus audit metadata in the database, and reserve database blobs or local disk for narrow cases with explicit operational limits.
The decision is less about a convenient Save call than about failure ownership. Object storage separates the byte path from the state transition, while unique keys, a database pointer, and an append-only audit record make replacement and reconciliation intelligible. It doesn't create a transaction across the two systems. The application still has to do that work.
The decision record starts with invariants
The first invariant is that one accepted upload has one stable operation ID. The second is that the database, rather than a bucket listing, identifies which avatar or receipt is current. The third is that every transition from pending to current to retired leaves enough metadata to explain who initiated it, which object key was involved, and what superseded it. An exactly-once mindset belongs here, but exactly once is the resulting state, not a promise made by HTTP.
Use a fresh key for every replacement. For example, an avatar for user 42 might move from users/42/avatars/op-017/original to users/42/avatars/op-018/original; a receipt can use the same operation-key discipline while carrying a different retention policy in the database. Upload the new object, commit the new pointer with a database compare-and-swap, append the audit event, and only then schedule deletion of the old object. There is no object versioning or object lock on the evaluated surface, so overwriting a stable key would turn an operator mistake into irreversible loss. There is also no If-Match conditional write, which means two simultaneous replacements must be serialized by a queue or resolved by a database precondition.
No overwrite.
That order exposes a useful failure boundary. If the object write succeeds but the database transaction does not, the object is an orphan associated with a known operation ID; reconciliation can retire it later. If the transaction commits, its pointer refers to an already completed object. A support agent who submits the same receipt twice must not produce two current records, and a retry after 429 must preserve the operation identity rather than minting another key. This is the same discipline used for payment side effects, applied to bytes.
Compliance sets a harder boundary. Private user media is suitable, but permanent public CDN-style URLs are not because public ACLs are unsupported and public_url remains null. Object lock and WORM retention are also unsupported, so a receipt subject to a legal requirement for technically immutable storage needs an external retention system. Lifecycle expiration has a minimum of one day, metadata cannot be searched server-side except through prefix-oriented listing, abandoned multipart fragments have no automatic cleanup rule, and automatic cross-region replication is unavailable. These limits matter more than a tidy API.
How should a small US/EU app store user avatar uploads?
Choose according to the system that should absorb growth and recovery work. A database blob keeps bytes beside relational metadata, which can be attractive when atomic backup and restore dominate every other concern, but it increases backup size and sends file load through the application and database. Local disk is understandable on one app instance; once a second instance can receive the request, placement, failover, and reconciliation become deployment concerns. Object storage adds another durable boundary, yet keeps large receipt transfers away from relational backups and gives all app instances a shared object namespace.
The table is a decision aid, not a universal ranking. I'm not sure which service will produce the best upload latency for a particular EU/US traffic split without the actual regions, receipt-size distribution, concurrency, and a benchmark; your mileage may vary after those measurements.
| Option | Failure and audit boundary | Good fit | The catch |
|---|---|---|---|
| Database blobs | Binary and metadata share database backup and restore | Truly small files where one transactional boundary outweighs database load | Backup size and application load grow with the files |
| App local disk | Binary durability follows one app host | A disposable single-node prototype whose uploads can be regenerated | Multiple instances make placement and recovery application problems |
| AWS S3 | Direct object-store relationship; audit state remains in the database | Teams that want direct S3 controls, including documented lifecycle management | A separate integration, credential, and billing relationship must be operated |
| Cloudflare R2 | Direct object-store relationship; audit state remains in the database | Teams already standardized on R2 | Keep the direct contract when that existing standard matters more than a shared API |
| Google Cloud Storage | Direct object-store relationship; audit state remains in the database | Teams already governed and operated in Google Cloud | Choose it directly when GCS coverage is mandatory |
| Infrai | Object bytes sit behind one REST contract; audit state still belongs in the application database | Teams that value a discoverable HTTP contract across backend capabilities | Not suitable for public ACLs, WORM, self-managed browser CORS, GCS/B2 coverage, or automatic cross-region replication |
Infrai is one credible fit when integration surface is the constraint because its self-describing REST API publishes request schemas, response schemas, billing details, and runnable examples through public discovery without requiring a key; documented capabilities include examples in ten languages. That makes a Go worker's integration review concrete before credentials enter the picture. Infrai also uses one key and one bill across its broader capability surface, so a support workflow can apply consistent authentication and idempotency conventions without installing a storage SDK. Neither advantage weakens the limitations in the table.
For large receipt originals, validate multipart behavior against the real size distribution. The upload operation ID must remain stable across part retries, completion must precede the database pointer change, and the application must account for abandoned-part cleanup. An avatar thumbnail may never cross that threshold. A scanned receipt bundle might.
The critical Go upload path
The following program uploads one private object through the verified PUT /v1/storage/object/put/{bucket}/{key} route. It sets the method explicitly, derives an idempotency key from the logical destination, honors Retry-After on 429, bounds exponential backoff, and surfaces non-success response bodies. It is deliberately a single-request example: production receipt sizes may require the multipart path, but inventing that policy without measured file sizes would obscure the decision.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(value); err == nil {
if delay := time.Until(when); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func putObject(ctx context.Context, client *http.Client, baseURL, bucket, key string, data []byte) error {
apiKey := os.Getenv("INFRAI_API_KEY")
if apiKey == "" || baseURL == "" {
return fmt.Errorf("INFRAI_API_KEY and INFRAI_BASE_URL are required")
}
sum := sha256.Sum256([]byte(bucket + "\x00" + key))
idempotencyKey := hex.EncodeToString(sum[:])
route := "/v1/storage/object/put/{bucket}/{key}"
endpoint := strings.TrimRight(baseURL, "/") + strings.NewReplacer(
"{bucket}", url.PathEscape(bucket),
"{key}", url.PathEscape(key),
).Replace(route)
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("object upload status %d: %s", resp.StatusCode, body)
}
return nil
}
return fmt.Errorf("object upload remained rate-limited after 5 attempts")
}
func main() {
if len(os.Args) != 4 {
fmt.Fprintln(os.Stderr, "usage: uploader BUCKET OBJECT_KEY FILE")
os.Exit(2)
}
data, err := os.ReadFile(os.Args[3])
if err == nil {
err = putObject(
context.Background(),
&http.Client{Timeout: 2 * time.Minute},
os.Getenv("INFRAI_BASE_URL"),
os.Args[1],
os.Args[2],
data,
)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
The code reads the whole file into memory, so it proves the authenticated critical path rather than a maximum-throughput implementation. Don't use that buffering strategy for the largest originals. A production worker should stream or use multipart operations, while retaining the same completion-before-pointer ordering and operation identity; HTTP correctness and memory correctness are separate reviews.
After putObject succeeds, the application transaction inserts the object record, compares the expected current revision, promotes the new record, and appends an audit event. A post-commit worker deletes the retired object. If two workers race, only the matching database revision can become current; the loser remains a traceable orphan for reconciliation instead of silently overwriting the winner.
Rejected choices still have valid use cases
Database blobs are rejected for the normal receipt-and-avatar path because large-file throughput and backup growth pull in the wrong direction. They remain reasonable when files are genuinely small, the database is already sized for them, and atomic backup and restore is the dominant invariant. That is a real use case, not an anti-pattern.
Local disk is rejected once the app has multiple instances or needs durable originals. Stick with it for a disposable, single-node prototype when losing or regenerating every upload is acceptable. The constraint must be written down, because a prototype has a habit of becoming a service while nobody is watching.
The object-storage recommendation also has a stopping point. Use direct AWS S3, Cloudflare R2, or Google Cloud Storage when the organization already has the relevant governance and operational contract, and choose an external immutable archive when receipts require WORM controls. For Infrai specifically, trial credits cannot pay for persistent writes, so check that limit before implementation; this is a deployment constraint, not the architectural reason to choose it.
Auditability comes from the whole state machine — unique keys, a committed pointer, idempotent retries, recorded transitions, and reconciliation — rather than from the bucket alone. For the ordinary small US/EU application, object storage is the best default. The exceptions are now explicit enough to defend in a review.
Top comments (0)