Replacing an avatar safely when two uploads race for the same key

Object storage has no compare-and-swap, so a same-key overwrite can't arbitrate a race. The immutable-key plus guarded-pointer pattern, and an idempotency trap to avoid.

The safe way to replace a user’s avatar is not to overwrite it. Write the new picture to a fresh, content-derived key, then flip a pointer row in your database with an update guarded by a revision number, then prune the old object once nothing references it. Infrai’s PUT /v1/storage/object/put/{bucket}/{key} carries no If-Match or expected-etag field, so a stale write and a fresh one look identical at the storage layer — the arbitration has to happen a layer up, in a system that can do a conditional update. That is a design boundary of object storage generally, not a quirk of this API.

Everything below is measured against a private Infrai bucket, because the failure modes here are specific and a couple of them are genuinely surprising.

What a same-key overwrite does

Nothing clever. The bytes are replaced, the etag changes, last_modified moves, and the previous version is gone — there’s no version id anywhere in the response because the API doesn’t expose object versioning.

export INFRAI_API_KEY=your_infrai_api_key

python3 -c "import base64,json; print(json.dumps({'data_base64': base64.b64encode(b'avatar-v1').decode(), 'content_type': 'image/png'}))" > v1.json

curl -sS -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kbg-avatarswap-0726/avatars/u_9001/current.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @v1.json
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_35718cfe04dc40a699e86c",
    "key": "avatars/u_9001/current.png",
    "size_bytes": 9,
    "etag": "0fef55c260eed80ab1c3eb24f48b81c6",
    "content_type": "image/png",
    "created_at": "2026-07-26T05:08:49.086227Z"
  }
}

The etag is your only witness that a write took effect. Hold onto it.

Scope the idempotency key to the bytes, not to the user

object/put takes an idempotency key two ways — an idempotency_key field in the JSON body, or an Idempotency-Key HTTP header — and both are scoped to the whole request, content included. That is the behaviour worth internalising before you pick a key format, because it decides whether a retry is safe or a replacement is refused.

Reuse a key with the same bytes and you get the original object back: one stored copy, one charge, which is what makes a network retry harmless. Reuse the same key with different bytes and the write is rejected outright:

python3 -c "import base64,json; print(json.dumps({'data_base64': base64.b64encode(b'avatar-v2-different').decode(), 'content_type': 'image/png', 'idempotency_key': 'avatar-u9001'}))" > v2.json

curl -sS -X PUT \
  "https://api.infrai.cc/v1/storage/object/put/kbg-avatarswap-0726/avatars/u_9001/current.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @v2.json
{
  "ok": false,
  "error": {
    "code": "IDEMPOTENCY_KEY_CONFLICT",
    "http_status": 409,
    "message": "Same idempotency_key, different params.",
    "retryable": false,
    "code_detail": "hash_mismatch"
  }
}

Verified 27 July 2026, and the header form answers identically — same code, same 409. So a per-user key like avatar-u9001 is the wrong shape: the second picture that user ever uploads collides with the first and you have to catch a 409 you didn’t design for. A per-content key is the right shape, and it costs nothing to build, because the content hash is what you’re about to use as the object key anyway. Then a retry after a timeout replays cleanly and a genuinely new picture takes a genuinely new key, with no branch in your code that has to tell the two apart.

Immutable keys, then a guarded pointer

Hash the incoming bytes, write to avatars/u_9001/<hash>.png, and make the database the single arbiter of which hash is current:

CREATE TABLE avatars (
  user_id     text PRIMARY KEY,
  object_key  text NOT NULL,
  revision    bigint NOT NULL DEFAULT 0,
  updated_at  timestamptz NOT NULL DEFAULT now()
);

-- The guard: a slower request can never demote a newer one.
UPDATE avatars
   SET object_key = $2, revision = $3, updated_at = now()
 WHERE user_id = $1
   AND revision < $3;
StrategyRace outcomeRollbackCleanup burden
Overwrite the same keyNon-deterministic; no way to detect the loserNone — bytes are goneZero
Immutable key + guarded pointer rowDeterministic; the higher revision winsRepoint the rowPrune orphans
Bucket versioning (S3, not available here)Storage layer arbitrates via conditional writeRestore a version idLifecycle on noncurrent versions

Two concurrent replaces now both succeed at the storage layer — they write different keys, so they don’t collide at all — and the database decides which one the product shows. The loser’s object is orphaned, not destructive, and a prune job collects it later.

import { createHash } from "node:crypto";

const API = "https://api.infrai.cc";
const BUCKET = "kbg-avatarswap-0726";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

/** @param {Buffer} bytes @param {string} userId @param {number} revision */
export async function replaceAvatar(bytes, userId, revision, db) {
  if (bytes.length > 5 * 1024 * 1024) throw new Error("avatar over 5 MB");
  const hash = createHash("sha256").update(bytes).digest("hex").slice(0, 16);
  const objectKey = `avatars/${userId}/${hash}.png`;

  const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${objectKey}`, {
    method: "PUT",
    headers: {
      authorization: `Bearer ${KEY}`,
      "content-type": "application/json",
      "Idempotency-Key": `avatar-${userId}-${hash}`,
    },
    body: JSON.stringify({
      data_base64: bytes.toString("base64"),
      content_type: "image/png",
      metadata: { "user-id": userId, "avatar-rev": String(revision) },
    }),
  });
  if (!res.ok) throw new Error(`put failed: ${res.status} ${await res.text()}`);

  const previous = await db.oneOrNone("SELECT object_key FROM avatars WHERE user_id = $1", [userId]);
  const won = await db.result(
    "UPDATE avatars SET object_key = $2, revision = $3, updated_at = now() WHERE user_id = $1 AND revision < $3",
    [userId, objectKey, revision],
  );
  return { objectKey, applied: won.rowCount === 1, orphaned: won.rowCount === 1 ? previous?.object_key : objectKey };
}

Notice the metadata keys. Underscores and hyphens are both accepted on the way in, and both come back hyphenated: send {"user_id": "9001"} and you read {"user-id": "9001"}, because HTTP header names are the storage layer’s native representation and the API normalises to them. Write hyphens in your own code anyway — matching what you’ll read back saves a round of confusion when someone compares the two. They show up in GET /v1/storage/object/head/{bucket}/{key}:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kbg-avatarswap-0726/avatars/u_9001/meta.png" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

That returns "metadata": {"avatar-rev": "7", "user-id": "9001"} alongside the size and etag. One more thing worth flagging from the schema: POST /v1/storage/object/set_metadata/{bucket}/{key} replaces the metadata map rather than merging into it, so a partial update wipes the keys you didn’t send.

Pruning the loser

Once the pointer has moved and any cached URL has expired, delete the orphan. Batch it — POST /v1/storage/object/delete_batch/{bucket} takes up to 1000 keys and is free:

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kbg-avatarswap-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"keys":["avatars/u_9001/orphan-a.png","avatars/u_9001/orphan-b.png"]}'

Give it a lag of a day or so. A signed URL minted before the swap still resolves until its TTL runs out, and deleting the object underneath it turns a working image into a 404 mid-session.

Cost, and when something else fits better

Writes are $0.0001 per object/put. Reads are not priced per call at all — object/get is metered on response bytes, $0.104 per GB — so the avatar swap itself is a rounding error and the bill is set by how large the pictures you serve are. Head, list and batch delete are free and rate-limited, and new accounts start with $2 free credit. Figures verified 27 July 2026 — this API’s rates have trended down, so check rather than trust:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" | \
  python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd')) for c in d['capabilities'] if c['id'].startswith('storage.object')])"

If you want the storage layer itself to arbitrate, S3 with bucket versioning plus conditional writes gives you real compare-and-swap semantics and Infrai doesn’t support that; buy S3 for this bucket when a stale overwrite is a correctness bug you can’t push into your own database, or Cloudflare R2 when serving the avatars is what dominates the bill.

The argument for doing it here is portability rather than features. Every call on this page is plain REST with a bearer token — no SDK to install, no client library to keep in step with a runtime upgrade, and an object layout (avatars/<user>/<hash>.png) that is S3-shaped by construction, so moving the bytes later is a copy job and not a rewrite. Meanwhile the rest of the swap needs no second account: the resize job goes to POST /v1/queue/publish, the orphan sweep to POST /v1/cron/create and the “avatar failed to publish” alert to POST /v1/errors/capture, all on the credential you already used for the write.

References

Browse more storage developer guides