Limits
Byte, pixel, and encode-attempt ceilings — and what actually bounds a running job.
This page is generated from — and kept in sync with, by a CI test — this API’s own
wrangler.jsonc vars. If this page and that file ever disagree, the file is right and this page
has a bug.
Supported formats
file must be JPEG, PNG, or WebP. HEIC and AVIF are refused with 415 unsupported-format,
before any decode is attempted — this is the single most likely trap for a new caller, since a
photo straight off an iPhone is HEIC by default.
This isn’t a missing feature waiting on a future release; it’s a real platform ceiling. HEIC
decode needs libheif-js, which compiles its wasm module at runtime with
new WebAssembly.Module(bytes) — and Cloudflare Workers disallow dynamic wasm compilation from
bytes at request time, the same restriction that blocks eval. This API’s wasm codecs are all
statically imported .wasm files, compiled by wrangler at deploy time instead; HEIC’s decoder is
the one codec that structurally can’t work that way here. AVIF’s reason is simpler: this API
bundles no AVIF decoder at all (the wasm AVIF decoder is roughly 8.4 MB and multi-second per
image, and there’s no canvas fallback available in a Worker).
HEIC decodes natively in the CLI and the web app, so
either one will convert a HEIC source for you first. AVIF is narrower: the CLI runs the same wasm
backend this API does and bundles no AVIF codec either (it exits 20 unsupported-format), so for
an AVIF source the web app — which decodes through the browser’s own image pipeline, wherever that
browser supports AVIF — or any other converter is the way in.
The numbers
| Var | Value | What it bounds |
|---|---|---|
MAX_INPUT_BYTES |
26,214,400 bytes (25 MiB) | the upload — refused as 413 input-too-large, from the Content-Length header alone when present, before the body is read |
MAX_MEGAPIXELS |
20 MP | decoded (or requested output) pixel dimensions — refused as 422 dimension-exceeded, checked from the file header before a full decode |
MAX_ENCODES |
8 attempts | how many encode probes the target-size search can make on one request — the search reports target-unreachable / BUDGET_EXHAUSTED rather than running indefinitely |
WALL_CLOCK_MS |
25,000 ms | see Why the wall clock isn’t the real bound below — this number is configured but not what you should rely on |
Sizes are 1024-based throughout this API, matching the rest of the product: target=50kb means
51,200 bytes, and the 25 MiB input ceiling above is 25 * 1024 * 1024 bytes, not 25,000,000.
The 20 MP ceiling is measured, not estimated
MAX_MEGAPIXELS: 20 isn’t a number picked from a spec — this API’s codec decodes at full size
before any resize, so a 20 MP source is roughly 80 MB of RGBA sitting in an isolate whose memory
ceiling (128 MB) also has to hold the wasm heap, the original source bytes, and the encoder’s own
working set. Whether that actually fits was checked directly: a real 5477×3651 (20.00 MP) JPEG at
quality 85 (9.03 MB) was posted against a running instance 30 times across three separate runs —
5 requests sequential, 5 concurrent, per run — targeting 30 KB. All 30 requests returned 200,
converging on the identical 28,357-byte result at 7 encode attempts every time, deterministic
across all three runs. No crash, no OOM, at the full configured 20 MP ceiling.
One honest caveat carries forward from that measurement: it ran against local wrangler dev, and
the 5-concurrent phase’s wall times formed a clean staircase (roughly 15s / 30s / 45s / 61s / 79s)
— strongly suggesting local workerd serialises CPU-bound synchronous work rather than running it
in true parallel across concurrent requests. That’s a property of the local dev runtime, not a
claim about Cloudflare’s edge: real concurrent-isolate behaviour under load has not yet been
verified against a deployed instance. Treat “20 MP holds” as proven for a single request’s
resource footprint, and treat concurrent-load behaviour at the edge as still open.
Why the wall clock isn’t the real bound
WALL_CLOCK_MS: 25000 exists, is threaded into an AbortController, and an abort is mapped to
503 processing-timeout when it fires — but don’t read that as “no request runs longer than 25
seconds.” A wall-clock deadline cannot reliably bound a Worker that performs no I/O:
runJob’s decode-and-search chain over an in-memory buffer never awaits anything that yields to a macrotask, so thesetTimeoutbacking the abort may never get a turn to run during an unbroken stretch of synchronous compute.- Cloudflare documents that
Date.now()inside a Worker returns the time of the last I/O, frozen for the duration of a pure-compute stretch — and this API’s codecs are all statically bundled, so a compress job performs no I/O at all once it starts. ADate.now() >= deadlinecheck may never observe the deadline passing during the compute itself.
This was confirmed empirically, not just reasoned about: in the same load measurement above, the
5th concurrent request took 79 seconds of wall time — more than 3× the configured 25-second
budget — and still returned a clean 200, never the 503 the abort machinery is supposed to
produce. The abort path is correct on the occasions it does fire (a request whose deadline had
already passed before the job even started, for instance), but it is not the mechanism actually
protecting this endpoint’s cost.
What actually bounds a running job’s cost is everything else this API already refuses on:
MAX_MEGAPIXELS bounds the pixel cost of a single decode/encode, MAX_ENCODES bounds the number
of probes a target search can run (what bounds cost accumulating across a slow search), and the
platform’s own cpu_ms: 60000 limit is the final backstop — a different mechanism entirely, an
isolate-level kill this API’s own code can’t observe or improve on. If you need a client-side
timeout, set one on your own request — don’t rely on 503 processing-timeout arriving reliably.