A Vue 3 upload bar that tells the truth about where the bytes are

axios onUploadProgress is easy. Making the percentage honest when the file crosses two hops — browser to Node, Node to private storage — takes a little more care.

onUploadProgress in axios reports bytes leaving the browser. That’s it — and whether those bytes are landing in your bucket or merely in your own Node process is a design decision the progress bar can’t see. With Infrai storage you’re on the two-hop path whether you like it or not, because a cross-origin PUT straight at the bucket fails its preflight and there’s no route to add a CORS rule, so this walkthrough builds the honest version: a Vue 3 component, a Fastify endpoint, and a bar that doesn’t hit 100% until the file is really stored.

Cloudflare R2 and Amazon S3 let you skip the middle hop; the last section says when that’s the better call.

The component

<script setup>
import { ref } from "vue";
import axios from "axios";

const props = defineProps({ userId: { type: String, required: true } });
const percent = ref(0);
const phase = ref("idle");
const error = ref("");
let controller = null;

async function onPick(event) {
  const file = event.target.files?.[0];
  if (!file) return;
  if (file.size > 25 * 1024 * 1024) {
    error.value = "25 MB limit";
    return;
  }

  controller = new AbortController();
  percent.value = 0;
  phase.value = "transferring";
  error.value = "";

  const form = new FormData();
  form.append("file", file);

  try {
    const { data } = await axios.post(`/api/files/${props.userId}`, form, {
      signal: controller.signal,
      onUploadProgress: (e) => {
        if (!e.total) return;
        percent.value = Math.round((e.loaded / e.total) * 90);
        if (percent.value >= 90) phase.value = "storing";
      },
    });
    percent.value = 100;
    phase.value = `stored ${data.bytes} bytes`;
  } catch (err) {
    error.value = axios.isCancel(err) ? "cancelled" : (err.response?.data?.error ?? err.message);
    phase.value = "failed";
  }
}

function cancel() {
  controller?.abort();
}
</script>

<template>
  <div class="uploader">
    <input type="file" accept="application/pdf,image/png,image/jpeg" @change="onPick" />
    <progress :value="percent" max="100" />
    <span>{{ phase }}</span>
    <button v-if="phase === 'transferring'" @click="cancel">Cancel</button>
    <p v-if="error" class="err">{{ error }}</p>
  </div>
</template>

The 90% ceiling is the whole trick. Transfer to your server maps to 0-90, and the last 10 points are reserved for the hop your browser can’t observe — the one where Node hands the bytes to storage. Users read a bar that sits at 90% for two seconds as “nearly done”, which is accurate. They read one that sits at 100% for two seconds as broken.

The Fastify endpoint

import Fastify from "fastify";
import multipart from "@fastify/multipart";
import { randomUUID } from "node:crypto";

const app = Fastify({ bodyLimit: 26 * 1024 * 1024 });
await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });

const BUCKET = "kb-vue-files";
const ALLOWED = new Set(["application/pdf", "image/png", "image/jpeg"]);

app.post("/api/files/:userId", async (request, reply) => {
  const part = await request.file();
  if (!part || !ALLOWED.has(part.mimetype)) {
    return reply.code(415).send({ error: "unsupported file type" });
  }

  const bytes = await part.toBuffer();
  const key = `inbox/${request.params.userId}/${randomUUID()}.pdf`;
  const payload = JSON.stringify({ content_base64: bytes.toString("base64"), content_type: part.mimetype });

  const stored = await fetch(`https://api.infrai.cc/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}`, "Content-Type": "application/json" },
    body: payload,
  });
  if (!stored.ok) {
    request.log.error({ status: stored.status }, "storage write failed");
    return reply.code(502).send({ error: "storage rejected the file" });
  }

  const { data } = await stored.json();
  return reply.code(201).send({ key: data.key, bytes: data.size_bytes, etag: data.etag });
});

await app.listen({ port: 3000 });

Executable content types are refused at the storage layer with STORAGE_CONTENT_TYPE_BLOCKEDtext/html, application/javascript and friends — so the allowlist above isn’t only about your own product rules. Check it before the call and you’ll return a clean 415 instead of relaying a 415 you didn’t expect.

What the direct-to-bucket version looks like, and why it stops

For completeness, here’s the code everyone writes first. Against S3 or R2 it works and the progress bar covers the entire transfer:

import axios from "axios";

export async function directPut(signedUrl, file, contentType, onProgress) {
  return axios.put(signedUrl, file, {
    headers: { "Content-Type": contentType },
    onUploadProgress: (e) => e.total && onProgress(Math.round((e.loaded / e.total) * 100)),
  });
}

Point it at an Infrai signed URL from a browser and axios rejects with Network Error: err.response is undefined, err.request is set, and there’s no status code to log. That shape confuses people for hours, so read it precisely — an axios error with no response means the request never completed at the network layer, which for a cross-origin PUT almost always means the preflight was refused. The bucket answers OPTIONS with 403 AccessForbidden and the browser never sends your bytes.

Signing itself is fine and free, and the URL works everywhere a browser isn’t:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST \
  "https://api.infrai.cc/v1/storage/object/presign/kb-vue-files/inbox/u_2317/contract.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"op":"put","expires_seconds":600,"content_type":"application/pdf","max_bytes":26214400}'

React Native, an Electron main process, a desktop sync agent, a CI job — all of those can take that URL and PUT to it directly, because none of them enforce CORS.

Three shapes for a Vue upload

ShapeProgress coversCORS neededServer memoryWhere it fits
axios → your Node API → storageBrowser to your server onlyNoneWhole file per requestInfrai today; any backend behind a strict CSP
axios → presigned URLThe real transferYes, bucket rulesNoneS3, R2, Supabase Storage
axios → presigned parts (multipart)Per part, resumableYesNoneFiles above ~100 MB

Memory is the row people underestimate. Buffering a 25 MB file per request is fine at ten uploads a minute and painful at two hundred — if your traffic is closer to the second number, stream the body through rather than calling toBuffer(), or move to a backend that lets the browser upload directly and keep your Node process out of the data path entirely.

Confirming the file exists

The cheapest confirmation is a metadata read, which is free:

curl -sS -X GET \
  "https://api.infrai.cc/v1/storage/object/head/kb-vue-files/inbox/u_2317/contract.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "found": true,
    "status": "found",
    "key": "inbox/u_2317/contract.pdf",
    "size_bytes": 9,
    "etag": "6446a98080f5e51ab7f0abc0e8eda635",
    "content_type": "application/pdf"
  }
}

For downloads, mint a read URL with {"op":"get","expires_seconds":600} — with one caveat worth knowing before you call the bucket private in a security review. We stripped the query string off a signed GET and fetched the bare path: it returned 200 and the file. The signature has an expiry, but it isn’t what’s keeping strangers out, so use random keys and gate anything genuinely confidential behind your own session check.

Cost, and the live number

Presign, head and list are free and rate-limited; the write bills $0.0001 per call and a body read $0.0002, verified 26 July 2026, with $2 of free credit on a new account. One upload is one write regardless of file size, so a progress bar has no billing implications at all:

curl -sS -X GET "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')]"

Stored bytes and egress are metered on their own and will dominate the invoice long before call fees do. Rates drift down over time, so treat those as a ceiling and check GET /v1/account/usage once real traffic is flowing.

References

Browse more storage developer guides