Lens

Concepts

The pipeline

What happens between handing Lens an image and getting URLs back, and where each option takes effect.


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.

01

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.

02

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.

03

Validate

Size, pixel count, dimensions and format allowlist. All before a decoder ever sees the bytes.

04

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.

05

Encode and store

Bounded concurrency, four at a time by default. Each variant is encoded, keyed, then uploaded with retries.

06

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 variants

Order 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' });
ModeBehaviour
falseDefault. 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.
CacheStoreYour 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;
}
CodeStatusMeans
INVALID_SOURCE400source was not a shape Lens understands
SOURCE_UNREADABLE400The file could not be read
UNSUPPORTED_INPUT415Bytes are not a recognisable image
VALIDATION_FAILED422Failed a size, dimension or format rule
INVALID_OPTIONS400Contradictory or out-of-range options
ENGINE_UNAVAILABLE500No codec, usually sharp is not installed
ENCODE_FAILED500The codec threw
ALL_FORMATS_FAILED500Nothing could be produced, fallback included
UPLOAD_FAILED502Storage failed after retries
ADAPTER_REQUIRED500The operation needs an adapter
ADAPTER_UNSUPPORTED501The adapter lacks an optional method
ADAPTER_MISCONFIGURED500Bad adapter construction options
ABORTED499Your 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 .jpg that 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.