Every optimize() call runs the same six stages. Knowing which stage an option belongs to is most of what you need to reason about the result.
Read
A path, Buffer, URL, stream or { data, filename } becomes bytes plus a name. Streams are drained with the size limit applied as they go, so an oversized upload never fully lands in memory.
Sniff
Format and dimensions are read from the file header in pure TypeScript. Not from the file extension, which a caller controls and an attacker can lie about.
Validate
Size, pixel count, dimensions and format allowlist. All before a decoder ever sees the bytes.
Plan
Formats x sizes, plus the thumbnail, expanded into a flat work list. Unsupported formats are dropped here with a warning rather than failing mid-run.
Encode and store
Bounded concurrency, four at a time by default. Each variant is encoded, keyed, then uploaded with retries.
Assemble
Variants grouped by format, srcsets built, savings computed, warnings collected.
Validation comes first, deliberately
The guard that matters most is maxPixels. A 40,000 × 40,000 PNG of flat colour compresses to a few hundred kilobytes and expands to roughly six gigabytes of RGBA in memory. A byte-size limit does not catch it; a pixel limit does.
validate: {
maxBytes: 25 * 1024 * 1024, // default
maxPixels: 100_000_000, // default. The decompression-bomb guard
allowedFormats: ['jpeg', 'png', 'webp', 'avif'],
minWidth: 200,
}This is why Lens ships its own header parser rather than asking the codec. The check has to happen before the decoder allocates, and it has to work whether or not sharp is installed.
Planning
Formats and sizes multiply. Three formats at three widths is nine encodes, plus one more if you asked for a thumbnail.
formats: ['avif', 'webp', 'jpg'],
sizes: [{ width: 1200 }, { width: 600 }, { width: 300 }],
thumbnail: true,
// → 3 formats x 3 sizes + 1 thumbnail = 10 planned variantsOrder matters. It is preserved into result.formats and therefore into the <source> order of a <picture>, which is what makes progressive enhancement work: put the most modern format first and the most compatible last.
Quality is per-format
The scales are not comparable. AVIF at 80 is wastefully large for the same perceived result, so its default is 60 while JPEG's is 82 and WebP's is 80. An explicit quality always wins over the defaults.
Never upscaling
A requested width larger than the source is clamped, so asking for { width: 4000 } on a 2000px image gives you a 2000px file rather than a blurry 4000px one. Opt out per size with withoutEnlargement: false.
Graceful degradation
Formats are independent. A failure in one is a warning, not an exception because the alternative is a 500 on an upload that could have succeeded in two formats out of three.
const result = await optimizer.optimize({ source, formats: ['avif', 'webp'] });
result.formats.avif; // undefined
result.formats.webp; // still here
result.warnings;
// [{
// code: 'format_unsupported',
// format: 'avif',
// message: 'The "sharp" engine cannot encode avif (common with prebuilt sharp
// binaries that ship without AV1); falling back to jpeg if nothing
// else succeeds.',
// }]This is not hypothetical. Prebuilt sharp binaries do not all ship AV1.
If every requested format fails, Lens retries the whole size ladder in fallbackFormat (JPEG by default). Only if that also fails does it throw ALL_FORMATS_FAILED. Set fallbackFormat: null to turn the safety net off.
Keys
The default template is {hash}/{name}-{label}.{ext}, content addressed, so identical bytes always produce an identical key and Cache-Control: immutable is safe by construction.
key: '{hash}/{name}-{label}.{ext}' // the default
key: '{format}/{width}x{height}.{ext}' // 'webp/1200x800.webp'
key: (ctx) => `users/${userId}/${ctx.hash}.${ctx.ext}`An unknown token throws rather than interpolating undefined into a filename. Two sizes that resolve to the same label within one format also throw, because they would overwrite each other and you would be one file short without noticing.
Retries
Adapter writes are retried with exponential backoff and full jitterthe delay is random between zero and the ceiling, not the ceiling itself. Fixed backoff makes every worker that failed on the same throttle retry at the same instant, reproducing the pile-up that caused it.
Transient failures and 5xx are retried; 4xx never is, because a 403 from a bad IAM policy will still be a 403 in 800 milliseconds. Encoding is not retried at all. It is deterministic, so a second attempt burns CPU to reach the same error.
Caching
The expensive part is the encode, and the output is a pure function of (source bytes, options). So the cache key is a hash of both, and a hit skips encoding and uploading.
new ImageOptimizer({ adapter, cache: 'storage' });| Mode | Behaviour |
|---|---|
false | Default. Always reprocess. |
'memory' | Process-lifetime LRU. Fast, but a fresh deploy starts cold. |
'storage' | Memory, plus adapter.exists() so a warm bucket short-circuits a cold process, and a stale hit is discarded if the object was deleted. |
CacheStore | Your own Redis or SQLite, shared across instances. |
Errors
Every failure is a LensError with a stable code and a suggested HTTP status, so route handlers do not have to map them.
import { LensError } from '@lens-image/core';
try {
await optimizer.optimize({ source, validate: { maxBytes: 5_000_000 } });
} catch (error) {
if (LensError.is(error, 'VALIDATION_FAILED')) {
return Response.json({ error: error.message }, { status: error.status }); // 422
}
if (LensError.is(error, 'UPLOAD_FAILED')) {
logger.error({ adapter: error.details.adapter, key: error.details.key });
}
throw error;
}| Code | Status | Means |
|---|---|---|
INVALID_SOURCE | 400 | source was not a shape Lens understands |
SOURCE_UNREADABLE | 400 | The file could not be read |
UNSUPPORTED_INPUT | 415 | Bytes are not a recognisable image |
VALIDATION_FAILED | 422 | Failed a size, dimension or format rule |
INVALID_OPTIONS | 400 | Contradictory or out-of-range options |
ENGINE_UNAVAILABLE | 500 | No codec, usually sharp is not installed |
ENCODE_FAILED | 500 | The codec threw |
ALL_FORMATS_FAILED | 500 | Nothing could be produced, fallback included |
UPLOAD_FAILED | 502 | Storage failed after retries |
ADAPTER_REQUIRED | 500 | The operation needs an adapter |
ADAPTER_UNSUPPORTED | 501 | The adapter lacks an optional method |
ADAPTER_MISCONFIGURED | 500 | Bad adapter construction options |
ABORTED | 499 | Your AbortSignal fired |
Safety defaults
On unless you turn them off, because getting them wrong is expensive:
- Decompression-bomb guard at 100 megapixels, checked from the header.
- Format sniffing, not extensions. A
.jpgthat is actually an SVG is detected as SVG. - Path-traversal refusal in the filesystem adapter.
- Remote sources off. Fetching user-supplied URLs is an SSRF surface; opt in with
allowRemote. - Metadata stripped. EXIF carries GPS coordinates. Opt back in with
preserveMetadata. - EXIF orientation applied before resizing, so portrait phone photos are not resized against the wrong axis.