Skip to content
scroll to zoom · drag to pan

Uploads that survive a reload

aq.upload() sends a file in a single PUT. That is what almost every consumer needs, and you should not reach past it without a reason.

The reason is resume. A single PUT is atomic in the worst way: interrupt it at 95% — a reload, a tunnel, an iOS app backgrounded by the user switching to Messages — and you start from zero. On a 2 GB video over a phone connection, that is not an inconvenience; it is a feature that does not work.

Two packages pick up there. Both are optional: install one only if the sentence above describes your product.

package resumes across
Browser @nitida/asset-uploader-web a page reload, a crashed tab, a closed laptop
Expo / React Native @nitida/asset-uploader-expo the app being backgrounded or killed
Terminal window
bun add @nitida/asset-uploader-web
import { UploadTask } from "@nitida/asset-uploader-web";
const task = new UploadTask({
endpoint: process.env.NEXT_PUBLIC_NITIDA_URL!,
tenantCode: "your-tenant",
file,
// Your OWN backend's token, not a nitida API key — see "The credential" below.
authToken: await getSessionToken(),
onProgress: ({ sentBytes, totalBytes }) =>
setPct(Math.round((sentBytes / totalBytes) * 100)),
});
const { assetId, sha256 } = await task.start();

Construct the same UploadTask again for the same file and it finds the in-flight session and continues. It matches on (tenantCode, sha256), not on a handle you have to keep — so a fresh page after a reload resumes without you having stored anything.

Terminal window
bunx expo install @nitida/asset-uploader-expo

The JS API is deliberately the same shape, so a call site written for the web reads identically. Underneath it is URLSession with a background configuration on iOS and WorkManager on Android: the OS keeps uploading after your process is gone, and hands the result back when the app returns.

It ships an Expo config plugin. Prebuild picks it up from the package root; you do not add anything to app.json beyond the plugin entry:

{ "expo": { "plugins": ["@nitida/asset-uploader-expo"] } }

The plugin appends UIKit’s background-URLSession event handler to your Swift AppDelegate. Without it, an upload that finishes while the app is suspended never fires its completion handler and the asset stays “uploading” forever — which looks exactly like a hung server and is not one.

The credential, and what lands in the browser

Section titled “The credential, and what lands in the browser”

⭐ An uploader never sees a nitida API key. authToken is your backend’s token; the uploader forwards it to your endpoint, which presigns against nitida with the key it holds server-side. If you find yourself putting an amk_* key in authToken, stop: you are about to ship a platform credential to every visitor.

The web uploader persists its session in IndexedDB, because that is what “resume after a reload” means. What is in there, stated so nobody has to discover it:

  • The part URLs — presigned R2 PUT URLs. These are bearer credentials, and the bounds are the point: write-only, to the parts of one upload the user themselves started, 60-minute TTL, IndexedDB scoped to your origin, and the row is deleted on complete and on abort.
  • Not the authToken. It lives in memory on the task and is never written down. UploadSession has no field for it — that is the enforcement, not the intention. A reload therefore loses it and your app supplies it again.
  • Files that comfortably fit one PUT. aq.upload() is fewer moving parts, and fewer moving parts is the whole argument.
  • A server-side script. There is no reload to survive; retry the PUT.
  • “Just in case.” Both packages add real bundle weight and a native module on the Expo side. A feature nobody reaches is a cost everybody pays.