Abandoned multipart uploads: invisible, persistent, and yours to govern

Half-finished parts survive a closed tab and no read route on Infrai will show them. What that means for billing, and the registry-plus-sweeper pattern that fixes it.

Yes, they persist. When a browser tab dies partway through an upload, the parts already written stay where the storage vendor put them, and nothing expires them on its own. On Infrai the sharper problem isn’t that they persist — it’s that you can’t find them afterwards. We opened an upload, wrote one 8 MiB part, then queried the bucket three different ways; every one of them reported an empty bucket.

So governance can’t be discovery-based here. It has to be bookkeeping you own, written at the moment the upload starts, because after the tab closes there’s no read route that will tell you the upload ever existed.

The invisibility, demonstrated

Open an upload and push a single part to its signed URL. Then ask the API what’s in the bucket:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-mpu-0726?prefix=incoming/" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-mpu-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "list": { "items": [], "next_cursor": null },
  "usage": { "byte_count": 0, "object_count": 0, "as_of": "2026-07-26T00:49:34.327437Z" }
}

Eight megabytes of uploaded parts, and byte_count says zero. Heading the key the upload was destined for is no better:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-mpu-0726/incoming/u_8123/holiday-reel.mp4" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": false,
    "status": "not_found",
    "key": "incoming/u_8123/holiday-reel.mp4"
  }
}

That’s correct behaviour — an incomplete upload isn’t an object yet — but it means GET /v1/storage/bucket/usage/{bucket} under-reports whatever your vendor is holding, and a dashboard built on it will look clean while the orphans accumulate underneath. Every read path in the storage namespace is object-oriented: list enumerates finished objects, head resolves a finished object, usage sums finished objects. An upload that never reached POST /v1/storage/multipart/complete/{upload_id} is not an object, so it appears in none of them, and there is no separate route that enumerates uploads in flight the way S3’s ListMultipartUploads does. The practical consequence is that the upload id printed in your server log at creation time is the only handle that will ever exist for those bytes.

Lose it and they’re unreachable.

The S3 reflex that doesn’t carry over

Most advice on this question is one line: add an AbortIncompleteMultipartUpload rule to the bucket lifecycle and let the platform sweep for you.

It’s good advice on S3. It doesn’t work here.

Infrai’s lifecycle rules take prefix, expire_days and transition_class, and that’s the whole vocabulary. There’s no incomplete-upload clause. The trap is that the endpoint won’t tell you so:

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

That returns 200 and echoes abort_incomplete_multipart_days straight back in lifecycle_rules, where it will sit forever looking like a policy. It isn’t one. Unknown rule keys are stored and reflected, never enforced — so a green response here is not proof that anything sweeps.

CapabilityInfraiAmazon S3Cloudflare R2
List in-flight multipart uploadsno routeListMultipartUploadsListMultipartUploads
Lifecycle rule to abort incomplete uploadsnot supportedAbortIncompleteMultipartUploadsupported
Abort a known upload by idDELETE /v1/storage/multipart/abort/{upload_id}, freeyesyes
Orphan bytes visible in usage reportingnovia storage metricsvia metrics

If governing abandoned uploads without writing any code is a hard requirement — a regulated bucket, or a team with no cron infrastructure — S3 or R2 is the better pick for that bucket, and it’s a small enough decision to make on its own merits.

Bookkeeping at open time

The fix is not clever.

Record the upload when you create it, clear the record when you complete it, and sweep anything left over. Three columns are enough: upload_id, key, opened_at. The example below keeps them in a JSON file so it runs as-is; in a real service that’s a row in your database, indexed on opened_at, and the shape doesn’t change at all.

import { readFile, writeFile } from "node:fs/promises";
import process from "node:process";

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

const REGISTRY = "./uploads-in-flight.json";
const MAX_AGE_HOURS = 12;

async function callApi(path, verb) {
  const res = await fetch(`${API}${path}`, {
    method: verb,
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, json };
}

async function loadRegistry() {
  try {
    return JSON.parse(await readFile(REGISTRY, "utf8"));
  } catch (err) {
    if (err.code === "ENOENT") return [];
    throw err;
  }
}

async function sweep() {
  const rows = await loadRegistry();
  const cutoff = Date.now() - MAX_AGE_HOURS * 3_600_000;
  const survivors = [];
  let aborted = 0;

  for (const row of rows) {
    if (Date.parse(row.opened_at) > cutoff) {
      survivors.push(row);
      continue;
    }
    const { status, json } = await callApi(`/v1/storage/multipart/abort/${row.upload_id}`, "DELETE");
    if (status === 409 || json?.data?.aborted === true) {
      aborted += 1;
      console.log(`swept ${row.key} (upload ${row.upload_id.slice(0, 12)}…, HTTP ${status})`);
      continue;
    }
    console.error(`could not abort ${row.upload_id}: HTTP ${status} ${json?.error?.code ?? ""}`);
    survivors.push(row);
  }

  await writeFile(REGISTRY, JSON.stringify(survivors, null, 2));
  console.log(`swept ${aborted} abandoned upload(s); ${survivors.length} still in flight`);
}

await sweep();

Twelve hours is a reasonable cutoff for browser-driven uploads — long enough that a genuinely slow 4 GB transfer on a bad connection survives, short enough that abandoned parts don’t overwinter. For a nightly batch job, an hour is plenty.

Run it on a schedule. Since the cron trigger, the storage bucket and the alert channel all sit on one Infrai key, that sweep is a scheduled job on the same account rather than a fourth vendor with its own invoice — which is the practical argument for keeping this on one platform even though the abort itself is trivial.

Abort semantics, precisely

curl -sS -X DELETE "https://api.infrai.cc/v1/storage/multipart/abort/17850269686e2589b2955fef430d473a5d29d9f34f486097eba8d94c4ecc7a91c4d0b172c9" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

A successful abort returns {"aborted": true}. Call it a second time and you get a 409:

{
  "ok": false,
  "error": {
    "code": "STORAGE_MULTIPART_INCONSISTENT",
    "http_status": 409,
    "message": "upload '1785026968...' not found",
    "retryable": false
  }
}

So abort is effectively idempotent but not literally so — the second call fails loudly. The sweeper above treats a 409 as success, which is the behaviour you want: whether this run cleaned it up or a previous run did, the upload is gone and the registry row should go with it. A sweeper that retries on 409 will grind forever on a row it can never clear.

The tab-close case is a bit different here

Worth flagging, because it changes who owns the orphan. Browser-direct multipart uploads against an Infrai bucket don’t work today — no route sets bucket CORS, cors_rules supplied at bucket creation is dropped, and an OPTIONS preflight to a presigned URL comes back 403. So the tab that closed was almost certainly talking to your server, which was talking to Infrai.

That’s mildly good news: your server already knows the upload id, so the registry write is a line of code in a handler you control, not a beacon you have to coax out of a dying browser. If you do need true browser-to-storage uploads, R2 or S3 will serve you better for that bucket, and both give you the incomplete-upload lifecycle rule as a bonus.

What the cleanup costs

DELETE /v1/storage/multipart/abort/{upload_id} is free and rate-limited, as are POST /v1/storage/multipart/create/{bucket} and GET /v1/storage/bucket/usage/{bucket} — verified 26 July 2026, and none of them consume the $2 credit a new account starts with. Only POST /v1/storage/multipart/complete/{upload_id} ($0.0002 per call) and per-part writes routed through the API ($0.0001 each) are billed. Check today’s figures:

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 'multipart' in c['id']]"

These rates trend down over time, so treat them as a ceiling rather than a forecast. The real exposure from abandoned uploads isn’t call charges at all — it’s stored bytes you can’t currently measure, which is exactly why the cutoff should be a policy you enforce rather than a number you tune from a graph.

References

Browse more storage developer guides