Use a private bucket with short-lived presigned URLs when an avatar belongs to exactly one user, and reach for a public CDN-backed bucket only when the images are genuinely public and you'd rather pay for cache hits than for signatures. For a Node.js SaaS that is the entire decision, and everything after it is plumbing: which S3-compatible provider you point at, how long a signature should live, and what happens to the stored object on the day a user deletes their account.

Avatars are small. That removes half the hard problems.

The half that's left is the half I get paged for, because an avatar key is written by an untrusted client, read on nearly every page render, cached in three places you don't control, and referenced from a database row that has its own opinion about which object is current. So the questions I ask a storage vendor aren't about upload throughput. They're about whether a partial write can ever be visible to a reader, what the durability number is actually measuring, and how I reconcile the bucket with my user table after a failed deploy. I've never watched a team lose avatar bytes. I've watched several lose track of which bytes were current, which is the same outage with a friendlier root-cause section.