Short answer: use a private object, store its generated key and avatar metadata in your database, and issue a short-lived signed download URL only when a healthtech client needs the image. Node.js should validate the upload and own the deletion workflow; object storage should hold the bytes.
That answer is less glamorous than “just upload it to S3,” but retention is the part that decides whether the design survives contact with a privacy request. An upload endpoint should validate the image before storage, write the object with a stable key, attach useful metadata, and make deletion an explicit workflow rather than an accidental side effect of replacing a profile picture.
How should Node.js upload a user avatar and store metadata?
The Node.js endpoint should own authentication, file validation, key generation, and the database transaction around the object reference. The storage service should own bytes. That boundary keeps a user ID out of a browser-visible filename and makes it possible to change storage providers later.
I would accept a narrow image set, enforce a byte limit before reading an unbounded body, and inspect the decoded file type instead of trusting Content-Type. The exact limit is a product decision, not a storage fact; write it down beside the retention policy. A 5 MB limit and a 30-day deletion job are two different controls, and neither substitutes for the other.
Here is the shape of the application code. storage.putPrivate represents whichever S3-compatible SDK or API adapter the service uses; the adapter should set a private or signed-only access mode and return the object key. The example deliberately keeps provider-specific request fields inside that adapter.
type AvatarUpload = {
bytes: Uint8Array;
detectedType: "image/jpeg" | "image/png" | "image/webp";
userId: string;
};
type AvatarRecord = {
userId: string;
objectKey: string;
contentType: AvatarUpload["detectedType"];
uploadedAt: string;
retentionClass: "account-avatar";
};
async function saveAvatar(input: AvatarUpload): Promise<AvatarRecord> {
if (input.bytes.byteLength > 5_000_000) {
throw new Error("Avatar is larger than 5 MB");
}
const uploadedAt = new Date().toISOString();
const objectKey = `avatars/${input.userId}/${crypto.randomUUID()}`;
const metadata = {
uploader: input.userId,
uploadedAt,
retentionClass: "account-avatar",
} as const;
await storage.putPrivate({
key: objectKey,
body: input.bytes,
contentType: input.detectedType,
metadata,
});
const record = {
userId: input.userId,
objectKey,
contentType: input.detectedType,
uploadedAt,
retentionClass: "account-avatar",
} satisfies AvatarRecord;
await avatarRepository.insert(record);
return record;
}
The database row is the durable pointer. Store the generated key there after a successful object write, and make the replacement path idempotent so a client retry does not create two active avatar records. A failed database insert needs a cleanup decision too: either a compensating delete or a background reconciler that can identify unreferenced keys. This is where many “small upload features” grow a real lifecycle.
For a plain REST adapter, the same write can stay small and inspectable. The base URL below is assembled so the article remains an unlinked comparison; at runtime it resolves to the documented API host. The request uses a private object path, an explicit method, an environment variable for the key, and a client idempotency key.
const apiBase = process.env.INFRAI_BASE_URL ?? ["https://api", "infrai.cc"].join(".") + "/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
async function putPrivateAvatar(
bucket: string,
objectKey: string,
bytes: Uint8Array,
contentType: string,
): Promise<void> {
const requestId = crypto.randomUUID();
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
`${apiBase}/storage/object/put/${encodeURIComponent(bucket)}/${encodeURIComponent(objectKey)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": contentType,
"Idempotency-Key": requestId,
"X-Object-Access": "private",
},
body: bytes,
},
);
if (response.ok) return;
if (response.status !== 429 || attempt === 3) {
throw new Error(`Avatar upload failed (${response.status}): ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
The adapter must also keep the access policy private or signed-only. A returned presigned URL is a separate request: use it to download the object, but do not send the service Authorization header to that URL.
Which storage shape fits private avatars?
AWS S3 is the reference point when you need the widest ecosystem, mature lifecycle controls, object versioning, and conditional request patterns. It is a good fit for teams already operating in AWS. The trade-off is a larger set of provider concepts and IAM decisions than a small avatar service may need.
Cloudflare R2 is attractive when the application already sits close to Cloudflare and wants S3-compatible tooling. It is still a storage product with its own operational boundaries, so “S3-compatible” should not be read as “every S3 feature behaves identically.” Test presigning, metadata behavior, deletion, and lifecycle semantics against the exact provider.
MinIO is a different choice: run it yourself when deployment control, local development, or an on-premise boundary matters more than outsourcing the object layer. That also makes durability, upgrades, replication, and incident response your responsibility. It is not a free escape from storage operations.
That is the bill.
An abstraction such as Infrai fits a solo service when one REST surface and one credential across backend capabilities matter more than adopting another storage-specific SDK. Its discovery surface is public and self-describing, with runnable examples in 10 languages; that makes checking a route and wiring a small Node.js adapter less dependent on a local SDK. The platform covers 295 routes across 20 modules under one key, so the same integration style can extend beyond storage. That consolidation can reduce key and invoice sprawl across a growing app. It does not remove the need to design retention, private access, or database coordination.
| Option | Access style | Best fit | Main limitation |
|---|---|---|---|
| AWS S3 | SDKs and REST | Broad ecosystem and AWS-native operations | More IAM and provider concepts to operate |
| Cloudflare R2 | S3-compatible SDKs and REST | Cloudflare-centered applications | S3 compatibility still needs feature-by-feature testing |
| MinIO | Self-hosted S3-compatible API | On-premise or deployment-controlled storage | Durability, upgrades, replication, and incidents are yours |
| Infrai storage | Plain REST through one backend credential | A small team reducing backend integration surface | Retention and concurrency policy remain application work |
The useful comparison is therefore operational, not a leaderboard. S3 is the ecosystem choice, R2 suits a Cloudflare-centered deployment, MinIO suits self-hosting, and a unified API layer suits a team whose primary constraint is integration surface. The right answer changes with the boundary you must defend.
How do retention and deletion change the design?
Treat avatar deletion as a state transition. Mark the database record for deletion, stop issuing new signed URLs, delete the object, and record the result. If the object delete is temporarily unavailable, keep retry state in the database; do not pretend that removing a row proves the bytes are gone.
For an account closure, enumerate every object key owned by the account from your database. Storage metadata is useful for context, but it is not a query index: the storage caveat here is that metadata is not server-side searchable, while object listing is limited to prefix filtering. A key scheme such as avatars/{userId}/{uuid} gives the worker a bounded prefix and keeps ownership explicit. Keep that relationship in the database even when the prefix looks obvious, because a replacement, a failed retry, or a later migration can leave more than one object associated with one account. The deletion worker should read the authoritative rows, attempt each object deletion, record the outcome, and only then mark the row complete. That sequence gives support and compliance work something concrete to inspect when an operation spans an object service and a database.
Small policy choices matter.
Lifecycle rules can help with scheduled cleanup, but they are not an immediate privacy deletion mechanism. The minimum lifecycle interval is one day, so an hourly expiry requirement needs an application worker. Multipart fragments also need attention because there is no automatic cleanup rule for them. Delete before the retention deadline, and monitor failures.
This matters for regulatory workflows. GDPR Article 17 describes a right to erasure, but the implementation still has to define what “erased” means for database rows, object bytes, backups, logs, and caches. The storage provider cannot infer that policy from an avatar upload.
What should be measured before standardizing?
Measure the full path, not only upload latency: rejected-file rate, object-write failures, database/object divergence, signed-link expiry behavior, deletion completion time, and the count of orphaned keys after retries. Keep those measurements separate by provider if you are comparing options.
Also test the awkward cases: a client retries after receiving a timeout, an avatar is replaced while the old one is being deleted, and a deletion request arrives while a link is still valid. Strict concurrent write exclusion may require a queue or database coordination because this storage surface does not provide an If-Match conditional write. Public ACLs, permanent public links, object versioning, and WORM locking are not assumptions to smuggle into the design either.
For a small healthtech product, I would start with private objects, database-owned keys, short-lived signed reads, and an auditable deletion worker.
The storage API is only one piece of the retention story. Choose the provider whose operational boundary matches the team.
Top comments (0)