How it works, end to end
The most common source of confusion is not the API — it is where each thing happens. This page is the map.
One upload, start to finish
Section titled “One upload, start to finish”sequenceDiagram
autonumber
participant D as Browser / phone
participant S as Your server
participant N as nitida
participant R as Object storage
participant C as CDN
Note over D: compress in a worker,<br/>then sha256 — both on the device
D->>S: ask for an upload URL (sha, mime, bytes)
S->>N: presign 🔑 the only calls that need the key
N-->>S: signed URL + dedup check
S-->>D: signed URL
Note over D,R: the bytes skip your server entirely
D->>R: PUT the file
R-->>D: 200
D->>S: "the PUT succeeded"
S->>N: register
N->>N: variants · palette · (video: transcode, HLS)
N-->>S: assetId + status
N->>S: webhooks (optional)
C-->>D: every <img> and <video> from here on
What each boundary means:
- Compression and hashing are on the device. A 12 MP photo becomes ~700 kB before a byte crosses the network.
- The PUT skips your server entirely. A 400 MB video does not transit your API, cost you bandwidth twice, or sit in your memory. This is also why the bucket’s CORS policy is the one that matters — your app is not in that request, so no setting of yours can fix it.
- Only presign and register need the key, which is why they are on your
server, and why the browser client’s type refuses an
apiKey. - Variants, palette, transcode and CDN are ours.
What is yours and what is ours
Section titled “What is yours and what is ours”flowchart LR
subgraph YOU["your app"]
A["file picker,<br/>progress UI"]
B["one route<br/>~15 lines"]
E["what a palette<br/>or caption MEANS"]
end
subgraph SDK["the SDK"]
C["compress · hash ·<br/>resumable transport ·<br/>URL builders"]
end
subgraph NT["nitida platform"]
D["variants · palette ·<br/>transcode · HLS · CDN"]
end
A --> C --> B --> D --> E
That last box is the seam people ask about most. We produce the artifacts; what they mean is your product, not ours — see Beyond images.
Where compression runs, and how many at once
Section titled “Where compression runs, and how many at once”In a worker, on OffscreenCanvas, so the main thread never blocks —
scrolling stays smooth while a dozen photos encode.
Concurrency is adaptive and deliberately conservative:
| value | why | |
|---|---|---|
| CPU work concurrency | 2 | empirical sweet spot on an 8-core laptop |
| batch size, desktop | 4 | |
| batch size, mobile | 1 | sequential, to avoid OOM on low-end Android |
| core cap | 8 | respect navigator.hardwareConcurrency, never fan out to 32 workers on a big machine |
The mobile default is the one worth knowing: a phone running four decode-resize-encode pipelines at once does not go faster, it gets killed.
Variants versus transforms — two URL shapes
Section titled “Variants versus transforms — two URL shapes”This trips up almost everyone once.
flowchart TD
SHA["an asset = its SHA-256"]
SHA --> V["STORED VARIANT<br/>pre-generated at upload<br/><code>/<tenant b36>/v/<sha>-lg.webp</code>"]
SHA --> T["ON-THE-FLY TRANSFORM<br/>generated on first request<br/><code>/t/format=webp,width=640/<sha>.webp</code>"]
V --> V2["tenant-scoped ✓<br/>use for: the standard rungs,<br/>and ALL video"]
T --> T2["global, no tenant segment<br/>use for: a crop or width<br/>the ladder does not have"]
Transforms are content-addressed by SHA and resize from the source on demand, so they need no tenant segment. Variants are pre-generated per tenant and do.
What happens to a file that is not an image
Section titled “What happens to a file that is not an image”Four different paths, decided by MIME:
flowchart TD
U["upload"] --> K{"MIME?"}
K -->|"image/*"| I["resize ladder + palette<br/>→ WebP variants"]
K -->|"video/*"| V["background job:<br/>poster · mp4 · HLS · aiproxy"]
K -->|"audio/*"| A["transcode to MP3 inline<br/>+ keep the original"]
K -->|"anything else"| P["passthrough:<br/>stored raw, served as-is"]
See Documents, audio and everything else for what that last box does and does not give you.