Cleaning up old thumbnails when a user replaces an image

Sweep the prefix yourself with list plus batch delete, and keep lifecycle as a backstop — its floor is one day. Node example against Infrai object storage.

Sweep the prefix at replace time and treat lifecycle expiry as a safety net, not as the mechanism. When somebody swaps their cover photo, the three or four derived sizes from the previous version become garbage the moment the new ones commit, and on Infrai that cleanup is two free calls: GET /v1/storage/object/list/{bucket} to enumerate the prefix, then POST /v1/storage/object/delete_batch/{bucket} with the keys you no longer want.

The reason not to lean on a lifecycle rule alone is arithmetic. The shortest expiry these buckets accept is one day, so a user who re-crops their avatar six times in an afternoon leaves you eighteen dead thumbnails that all bill for at least 24 hours.

Three ways to not accumulate garbage

StrategyOld bytes gone whenCDN/browser cacheWhat bites you
Overwrite the same keyImmediatelyStale for hours — same URL, new bytesCache busting becomes your problem
New generation in the key + prefix sweepWithin the same requestClean, every version has its own URLYou must actually run the sweep
Lifecycle rule onlyAfter 1 day minimumCleanGarbage bills for a day; nothing is deterministic

Most image pipelines end up on the middle row, because the URL is the cache key and reusing it is how you get a support ticket about someone’s old profile picture still showing.

Look at what’s actually under the prefix

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS \
  "https://api.infrai.cc/v1/storage/object/list/kb-thumbs-gc-0726?prefix=thumbs/img_5501/" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "key": "thumbs/img_5501/g1/1024.webp",
        "size_bytes": 41000,
        "etag": "45171f614f06a4863c72a9e3481a3ff8",
        "content_type": null,
        "metadata": null,
        "created_at": "2026-07-26T01:07:25.545189Z",
        "last_modified": "2026-07-26T01:07:14Z"
      },
      {
        "key": "thumbs/img_5501/g2/256.webp",
        "size_bytes": 8200,
        "etag": "46b9fcfcda51004fd27937aadbb05f04",
        "content_type": null,
        "metadata": null,
        "created_at": "2026-07-26T01:07:25.545198Z",
        "last_modified": "2026-07-26T01:07:07Z"
      }
    ],
    "next_cursor": null
  }
}

Read that response carefully, because two fields will mislead you. content_type and metadata come back null in listings even when the object has both — head on the individual key returns them properly. And created_at is stamped when the listing runs, not when the object was written; every row in a page shares the same timestamp to the millisecond. last_modified is the field that tells the truth about age.

If you’re deciding what’s stale by timestamp, use last_modified. Nothing else in a listing is trustworthy for that.

The sweep

Enumerate every page, keep the generation you just wrote, delete the rest in chunks. next_cursor is the pagination contract — loop until it comes back null.

const API = "https://api.infrai.cc";
const BUCKET = "kb-thumbs-gc-0726";

const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };

export async function sweepOldThumbnails(imageId, keepGeneration) {
  const prefix = `thumbs/${imageId}/`;
  const stale = [];
  let cursor = null;

  do {
    const qs = new URLSearchParams({ prefix });
    if (cursor) qs.set("cursor", cursor);

    const listed = await fetch(`${API}/v1/storage/object/list/${BUCKET}?${qs}`, {
      method: "GET",
      headers,
    });
    const page = await listed.json();
    if (!listed.ok || page.ok === false) throw new Error(page?.error?.code ?? `HTTP ${listed.status}`);

    for (const item of page.data.items) {
      if (!item.key.startsWith(`${prefix}${keepGeneration}/`)) stale.push(item.key);
    }
    cursor = page.data.next_cursor;
  } while (cursor);

  let removed = 0;
  for (let i = 0; i < stale.length; i += 200) {
    const purged = await fetch(`${API}/v1/storage/object/delete_batch/${BUCKET}`, {
      method: "POST",
      headers,
      body: JSON.stringify({ keys: stale.slice(i, i + 200) }),
    });
    const outcome = await purged.json();
    if (!purged.ok || outcome.ok === false) throw new Error(outcome?.error?.code ?? `HTTP ${purged.status}`);

    removed += outcome.data.deleted.length;
    for (const failure of outcome.data.errors) {
      console.warn(`could not delete ${failure.key}: ${failure.code}`);
    }
  }
  return { scanned: stale.length, removed };
}

console.log(await sweepOldThumbnails("img_5501", "g2"));

Call it after the new sizes have committed, never before — if the sweep runs first and the resize job then fails, the user is looking at a broken image instead of an old one.

{
  "ok": true,
  "data": {
    "deleted": ["thumbs/img_5501/g1/256.webp", "thumbs/img_5501/g1/512.webp"],
    "errors": [{ "key": "thumbs/img_5501/g1/nope.webp", "code": "STORAGE_OBJECT_NOT_FOUND" }]
  }
}

A batch delete is partially successful by design: keys that were already gone land in errors with STORAGE_OBJECT_NOT_FOUND, and that’s normal in a retried job rather than a failure. AWS’s DeleteObjects behaves the same way, which is worth knowing if you’re porting a sweep across.

Spot-check one key afterwards. head costs nothing and answers HTTP 200 either way, so read the found field rather than the status code:

curl -sS \
  "https://api.infrai.cc/v1/storage/object/head/kb-thumbs-gc-0726/thumbs/img_5501/g1/256.webp" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The backstop rule, and its floor

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-thumbs-gc-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"thumbs/tmp/","expire_days":1}]}'

Ask for anything less than a day and you get a 400:

{
  "ok": false,
  "error": {
    "code": "STORAGE_INVALID_LIFECYCLE_RULES",
    "http_status": 400,
    "message": "rule #0: expire_days must be >=1"
  }
}

That floor isn’t an Infrai quirk — S3’s own expiry documentation describes day-granularity rules that run asynchronously, so objects can outlive their expiry by hours. Use the rule for scratch prefixes and for the keys your sweep missed because a process died mid-job. Don’t use it as the primary path.

Also on the limitation side: these buckets have no versioning route, so there’s no “restore the previous thumbnail” once a delete lands, and no per-object retention lock. If regulatory hold matters, that’s a real gap and S3 Object Lock or Backblaze B2’s file-lock feature is the right tool.

What the sweep costs

Nothing, which is the useful part. Verified 26 July 2026, object/list, object/delete and object/delete_batch are all free and rate-limited, while writes bill $0.0001 per call and reads $0.0002. Deleting garbage promptly is therefore pure savings on stored bytes — check the current catalogue yourself:

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

Prices move, generally downward, so treat those as today’s reading. What holds regardless: the cron that triggers this sweep, the queue that regenerates the thumbnails and the bucket they land in all sit behind one key and show up on one usage query, so “how much is tenant 42 costing us in derived images” is a query rather than a spreadsheet.

References

Browse more storage developer guides