Skip to content

Image Transformations

Image transformations let you serve optimized versions of your images automatically. Instead of manually creating multiple sizes, Pixflare generates them on-demand and caches them at the edge.

Why transformations matter

Serving the right image size saves bandwidth, speeds up your site, and improves user experience. A mobile device doesn't need a 4K desktop image. Transformations handle this automatically:

  • Bandwidth savings: A 512px image is 80-90% smaller than a 2048px original
  • Faster page loads: Smaller images mean quicker downloads
  • Modern formats: Automatic conversion to AVIF or WebP based on browser support
  • No manual work: Request any size via URL, generated on first use
  • Edge caching: Transformed images cached globally for instant delivery

Default Presets

Pixflare includes 9 built-in presets covering common use cases:

PresetDimensionsFit ModeDescription
w128Max width 128pxscale-downTiny thumbnails
w256Max width 256pxscale-downSmall previews
w512Max width 512pxscale-downMobile screens
w1024Max width 1024pxscale-downDefault, tablets and small desktops
w1536Max width 1536pxscale-downLarge desktops
w2048Max width 2048pxscale-downRetina displays
thumb128x128px squarecoverProfile pictures, grid thumbnails
og-image1200x630pxcoverSocial media cards (Twitter, Facebook, etc)
originalNo transformation-Serve the unmodified uploaded image

All width-based presets use scale-down fit mode, which means they shrink images to fit but never enlarge them. Aspect ratio is always preserved.

Default transform options

Every preset uses these defaults unless overridden:

  • Quality: 85 (on a 1-100 scale)
  • Format: Auto-negotiated (AVIF for modern browsers, WebP for older ones, original as fallback)
  • Metadata: Stripped (removes EXIF data for privacy and smaller file sizes)
  • Fit: scale-down (never enlarges images)

You can see the full preset configuration in packages/config/src/image-variants.ts.

Using Transformations

To request a transformed image, append the preset name to the URL:

# Original image
https://cdn.yourdomain.com/alice/vacation/beach.jpg

# 512px width version
https://cdn.yourdomain.com/alice/vacation/beach.jpg/w512

# Square thumbnail
https://cdn.yourdomain.com/alice/vacation/beach.jpg/thumb

# Social media card
https://cdn.yourdomain.com/alice/vacation/beach.jpg/og-image

The same works with short IDs:

# Original
https://cdn.yourdomain.com/i/abc123xyz

# Transformed
https://cdn.yourdomain.com/i/abc123xyz/w1024

What happens on first request

When you request a variant that hasn't been generated yet:

  1. Pixflare serves it on-demand using Cloudflare Image Resizing
  2. The transformed image is returned immediately
  3. A background job queues up to generate and store the variant in R2
  4. Future requests serve the pre-generated version from R2 (even faster)

If no preset is specified, the default preset (w1024) is served.

Format negotiation

Pixflare automatically serves the best format your browser supports:

  • Modern browsers (Chrome 85+, Edge 91+, Safari 16+): AVIF (best compression)
  • Older browsers with WebP support: WebP (good compression)
  • Legacy browsers: Original format (JPG, PNG, etc)

This happens transparently based on the Accept header. You don't need to do anything.

SVG handling

SVG images are never transformed. They're vector graphics and scale infinitely, so transformations don't make sense. Pixflare always serves the original SVG regardless of the preset requested.

Adding/Editing Presets

You can control which presets are available and add custom ones. There are two configuration methods: environment variables (simple) and code-based (full control).

Method 1: Environment variables

The quickest way to restrict or enable presets is via environment variables in your wrangler.toml or .env file.

ALLOWED_VARIANTS controls which presets users can request:

toml
[vars]
ALLOWED_VARIANTS = '["w128","w256","w512","w1024","w1536","w2048","thumb","og-image"]'

If someone requests a preset not in this list, they get a 400 error. This is useful if you want to disable expensive presets like w2048.

DEFAULT_VARIANT sets the preset served when none is specified:

toml
[vars]
DEFAULT_VARIANT = "w1024"

R2_CUSTOM_DOMAIN is required for transformations to work:

toml
[vars]
R2_CUSTOM_DOMAIN = "r2.yourdomain.com"

Without this, Pixflare serves original images instead of transformed ones. See the Requirements section below for setup.

Method 2: Code-based configuration

For full control, edit the preset definitions directly. This lets you add custom presets with specific dimensions and transform options.

Step 1: Edit the presets file

Open packages/config/src/image-variants.ts.

Add your preset to the VARIANT_PRESETS array:

typescript
export const VARIANT_PRESETS = [
  'original',
  'w128',
  'w256',
  // ... existing presets
  'w3072',  // Your new preset
] as const;

Then add the configuration to VARIANT_CONFIG:

typescript
export const VARIANT_CONFIG: Record<VariantPreset, VariantConfig> = {
  // ... existing configs
  w3072: {
    description: 'Max width 3072px for 4K displays',
    label: 'XXX-Large - 3072px',
    transform: {
      ...DEFAULT_TRANSFORM_OPTIONS,
      width: 3072,
      fit: 'scale-down',
    },
  },
};

Step 2: Update environment variables

Add your new preset to ALLOWED_VARIANTS:

toml
[vars]
ALLOWED_VARIANTS = '["w128","w256","w512","w1024","w1536","w2048","w3072","thumb","og-image"]'

Step 3: Rebuild and deploy

bash
# Rebuild the config package
cd packages/config
pnpm build

# Rebuild the API
cd ../api
pnpm build

# Deploy
pnpm deploy:prod

Step 4: Test

bash
curl https://cdn.yourdomain.com/owner/album/image.jpg/w3072

You can create presets with different fit modes, quality settings, or other options. See the Advanced Configuration section for all available options.

How it Works

This section explains the technical implementation of image transformations, including the request flow, caching strategy, and background generation.

Request flow

mermaid
sequenceDiagram
    participant Client
    participant Gateway as Gateway Worker
    participant API as API Worker
    participant D1 as D1 Database
    participant R2 as R2 Storage
    participant CF as Cloudflare Image Resizing
    participant Queue as Queue (Background)

    Client->>Gateway: GET /alice/vacation/beach.jpg/w512
    Gateway->>API: Route to CDN handler
    API->>D1: Check image exists, privacy, deleted

    alt Image not found or deleted
        D1-->>API: Not found
        API-->>Client: 404 or 410
    end

    API->>R2: Try fetch pre-generated variant

    alt Variant exists in R2
        R2-->>API: Return variant
        API-->>Client: 200 with transformed image<br/>(Cache-Control: immutable, 1 year)
    else Variant not generated yet
        R2-->>API: Not found
        API->>R2: Fetch original image
        R2-->>API: Original image data
        API->>CF: Transform on-demand<br/>(via cf.image fetch option)
        CF-->>API: Transformed image
        API->>Queue: Enqueue background generation
        API-->>Client: 200 with transformed image
        Queue-->>R2: Store variant for next time
    end

The flow is optimized for speed. Pre-generated variants are served directly from R2. New variants are transformed on-demand and cached, then persisted in the background.

Implementation details

The CDN request handler lives in packages/api/src/routes/cdn.ts (lines 345-616).

1. Security checks

Before serving any image, Pixflare validates:

  • Image exists in the database
  • Not marked as deleted (returns 410 Gone if deleted)
  • Privacy settings (private images require authentication)
  • Upload completed (not just a created stub)

These checks are atomic to prevent time-of-check-time-of-use (TOCTOU) vulnerabilities.

2. Variant lookup

Pixflare looks for the pre-generated variant in R2:

typescript
const variantKey = getVariantKey(owner, album, filename, variant);
// Example: alice/vacation/beach.jpg/__variants/w512.jpg
const variantObject = await getObject(bucket, variantKey);

If found, it's served immediately with aggressive caching headers:

typescript
'Cache-Control': 'public, max-age=31536000, immutable'

This tells Cloudflare and browsers to cache the image for a year. Images are immutable (URLs never change), so this is safe.

3. On-demand transformation

If the variant doesn't exist, Pixflare generates it on the fly using Cloudflare Image Resizing. This requires the R2_CUSTOM_DOMAIN to be configured.

The transformation happens via Cloudflare's cf.image fetch option:

typescript
const r2ImageUrl = `https://${r2CustomDomain}/${originalKey}`;
const cfOptions = buildCfImageOptions(variant);

const transformedResponse = await fetch(r2ImageUrl, {
  cf: cfOptions as RequestInitCfProperties
});

You can see the buildCfImageOptions function in packages/api/src/lib/variants.ts.

Cloudflare does the heavy lifting:

  • Fetches the original from R2
  • Applies the transformation (resize, format conversion, etc)
  • Returns a cf-resized header if successful
  • Handles format negotiation based on the Accept header

4. Background generation

After serving the on-demand transformation, Pixflare enqueues a background job to persist the variant in R2. This makes future requests faster (no transformation needed).

The queue consumer lives in packages/api/src/queue/variant-consumer.ts.

Note: The queue consumer currently has a placeholder for actual transformation logic (lines 147-154). On-demand transformation works perfectly, but background persistence needs implementation. For now, it stores the original image data.

Storage structure

Pixflare stores images and variants in R2 with this structure:

# Original images
owner/album/filename.jpg

# Variants
owner/album/filename/__variants/w512.jpg
owner/album/filename/__variants/thumb.jpg
owner/album/filename/__variants/og-image.jpg

This keeps variants organized under the original image's path. You can see the key-building functions in packages/shared/src/urls.ts.

Caching architecture

Pixflare uses a multi-layer caching strategy for maximum performance:

Layer 1: Cloudflare Edge Cache (automatic)

All images are cached at Cloudflare's 300+ edge locations globally. The Cache-Control: immutable header ensures aggressive caching. Cloudflare automatically purges these when you delete an image via the Cloudflare API.

Layer 2: R2 Storage (persistent)

Pre-generated variants are stored in R2. Once a variant is generated and stored, it's served from R2 forever (until the image is deleted). No transformation needed.

Layer 3: KV Cache (rate limiting)

KV is used to rate-limit on-demand variant generation per IP address. This prevents abuse (someone requesting 1000 different custom transformations).

Cache invalidation

When you delete an image, Pixflare purges all variants from Cloudflare's edge cache using the Cloudflare API. This ensures deleted images disappear immediately (no stale cache).

The implementation is in packages/api/src/lib/cache.ts.

You need these environment variables for cache purging:

  • CLOUDFLARE_ZONE_ID: Your Cloudflare zone ID
  • CLOUDFLARE_API_TOKEN: API token with cache purge permissions

Requirements

R2 custom domain (required)

Image transformations only work if you configure a custom domain for your R2 bucket. Without it, Pixflare falls back to serving original images.

Setup:

  1. Go to your R2 bucket in the Cloudflare dashboard
  2. Click Settings
  3. Add a custom domain (e.g., r2.yourdomain.com)
  4. Configure DNS as instructed by Cloudflare
  5. Add the domain to your config:
toml
[vars]
R2_CUSTOM_DOMAIN = "r2.yourdomain.com"

This is required because Cloudflare Image Resizing needs a public URL to fetch the original image.

Cloudflare Workers paid plan (optional)

Background variant generation via queues requires a Workers paid plan (around $5/month). Without it, transformations still work perfectly fine via on-demand generation. You just won't get persistent storage of variants in R2.

Free tier: On-demand transformation works, variants not stored Paid tier: On-demand + background storage for faster future requests

To enable queues, add this to your wrangler.toml:

toml
[[queues.producers]]
binding = "VARIANT_QUEUE"
queue = "pixflare-image-processing-queue"

[[queues.consumers]]
queue = "pixflare-image-processing-queue"
max_batch_size = 5
max_batch_timeout = 30
max_retries = 3

And set the environment variable:

toml
[vars]
ENABLE_QUEUES = "true"

Environment variables summary

Required:

  • R2_CUSTOM_DOMAIN: Custom domain for your R2 bucket
  • ALLOWED_VARIANTS: JSON array of allowed preset names
  • DEFAULT_VARIANT: Preset to use when none specified

Optional:

  • ENABLE_QUEUES: Enable background variant generation (requires paid plan)
  • CLOUDFLARE_ZONE_ID: For cache purging on delete
  • CLOUDFLARE_API_TOKEN: For cache purging on delete

See the full list in .env.example.

Advanced Configuration

Beyond the simple width-based presets, you can configure transformations with many options. These are defined in the transform object of your preset configuration.

Fit modes

The fit option controls how images are resized:

  • scale-down (default): Shrink to fit within dimensions, never enlarge, preserve aspect ratio
  • contain: Fit within dimensions, may add letterboxing, preserve aspect ratio
  • cover: Fill dimensions exactly, crop if needed
  • crop: Same as cover but respects gravity setting
  • pad: Fit within dimensions with background padding
  • squeeze: Stretch or compress to fill (distorts the image)

Most presets use scale-down because it's safe and predictable. Use cover for thumbnails where you want exact dimensions (like the thumb and og-image presets).

Output formats

The format option forces a specific output format:

  • avif: Best compression, newer browsers only
  • webp: Good compression, wide browser support
  • jpeg: Universal compatibility
  • baseline-jpeg: Progressive JPEG (loads gradually)
  • png: Lossless with transparency support
  • undefined (default): Auto-negotiate based on browser support (recommended)

In most cases, leave format undefined. Cloudflare will automatically serve AVIF to modern browsers and WebP to older ones.

Quality

The quality option controls compression (1-100):

  • 100: Maximum quality (largest file)
  • 90: High quality, minimal artifacts
  • 85: Default, good balance
  • 75: Medium quality, smaller files
  • 60: Low quality, very small files
  • 40: Minimum quality (heavy artifacts)

Higher quality means larger files. The default (85) is a good balance for most use cases.

Gravity and focus

When using cover or crop fit modes, gravity controls where the image is cropped:

  • auto (default): AI-detected important areas (faces, subjects, etc)
  • face: Focus on faces
  • center: Center crop
  • left, right, top, bottom: Edge-aligned crops
  • {x: 0.5, y: 0.5}: Custom coordinates (0.0-1.0 range)

The thumb and og-image presets use auto gravity for smart cropping.

Effects

You can apply visual effects:

  • blur: 1-250 (blur radius in pixels)
  • sharpen: 0-10 (sharpening amount)
  • brightness, contrast, saturation, gamma: Adjust image properties

These are rarely needed but available if you want to add filters.

Metadata handling

The metadata option controls EXIF data:

  • none (default): Strip all metadata (smaller files, better privacy)
  • copyright: Keep copyright info only
  • keep: Preserve all metadata

Pixflare strips metadata by default to reduce file size and protect user privacy (EXIF can contain GPS coordinates, camera info, etc).

Size limits

Cloudflare Image Resizing has these limits:

  • Maximum width: 4096px
  • Maximum height: 4096px
  • Maximum DPR (device pixel ratio): 3

Requests exceeding these limits will fail.

Example custom preset

Here's a preset for high-quality product photos with specific dimensions:

typescript
'product-hero': {
  description: 'Product page hero image',
  label: 'Product Hero - 1400x900',
  transform: {
    width: 1400,
    height: 900,
    fit: 'cover',
    gravity: 'auto',
    quality: 90,
    format: undefined,  // Auto-negotiate
    metadata: 'none',
  },
}

All available options are typed in packages/config/src/image-transforms.ts.

Security & Performance

Rate limiting

On-demand variant generation is rate-limited per client IP to prevent abuse. By default, the limit is 100 requests per 60 seconds.

If someone exceeds this, they get a 429 Too Many Requests error. Pre-generated variants (already in R2) are not rate-limited.

The rate limiting logic is in packages/api/src/lib/rate-limit.ts.

Privacy controls

Private images require authentication before serving:

  • API keys (checked via database lookup)
  • JWT tokens (from Cloudflare Access or Auth.js)
  • Owner verification (must be authenticated as the image owner)

The authentication check happens before fetching the image from R2, preventing TOCTOU vulnerabilities.

Performance optimizations

Pixflare uses several strategies to maximize performance:

1. Immutable caching

Images use Cache-Control: public, max-age=31536000, immutable headers. This tells browsers and CDNs to cache aggressively (1 year). Since image URLs never change, this is safe.

2. Edge delivery

All images are served via Cloudflare's global CDN network. Users always fetch from the nearest edge location.

3. Format auto-negotiation

Cloudflare automatically serves AVIF (60% smaller than JPEG) to modern browsers and WebP (25% smaller) to older ones. You get this for free without configuration.

4. Lazy generation

Variants are only generated when requested, not on upload. This saves storage and processing time. Most images never need all variants.

5. Background persistence

Once a variant is requested, it's stored in R2 for instant future delivery. No transformation needed on subsequent requests.

6. CORS optimization

CORS headers allow images to be embedded anywhere:

typescript
'Access-Control-Allow-Origin': '*'
'Access-Control-Max-Age': '86400'  // Cache preflight for 24 hours

This prevents unnecessary preflight requests.

Monitoring

Pixflare tracks image serves using Cloudflare Analytics Engine. You can see:

  • Which variants are most popular
  • Total bandwidth saved by transformations
  • Cache hit rates
  • Geographic distribution of requests

This helps you optimize your preset configuration based on actual usage.

Released under the MIT License.