Image optimization with Cloudflare Workers and Ittybit

View Markdown

Serve resized, format-converted images on the fly without pre-generating every variant. A Cloudflare Worker sits at /images/:id, parses transform parameters from the query string, checks the Cache API first, and calls Ittybit on a miss. Processed images land in R2 so subsequent requests skip both the cache lookup and the API call.

Create an image task

Ittybit image tasks accept width, height, format, and quality options. The task runs asynchronously — you poll for the result or use a webhook.

const res = await fetch("https://api.ittybit.com/jobs", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ITTYBIT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    input: "https://cdn.example.com/originals/photo-abc123.jpg",
    kind: "image",
    options: {
      width: 800,
      format: "webp",
      quality: "high",
    },
  }),
});
const task = await res.json();
// { id: "task_xyz789", status: "pending", ... }
curl -X POST https://api.ittybit.com/jobs \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "https://cdn.example.com/originals/photo-abc123.jpg",
    "kind": "image",
    "options": {
      "width": 800,
      "format": "webp",
      "quality": "high"
    }
  }'

Poll for completion

Jobs typically complete in a few seconds for images. Poll GET /jobs/:id until status is "completed".

async function waitForTask(taskId: string, apiKey: string): Promise<any> {
  while (true) {
    const res = await fetch(`https://api.ittybit.com/jobs/${taskId}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const task = await res.json();

    if (task.status === "completed") return task;
    if (task.status === "failed") throw new Error(`Task ${taskId} failed`);

    await new Promise((r) => setTimeout(r, 1000));

}
}
curl https://api.ittybit.com/jobs/task_xyz789 \
  -H "Authorization: Bearer $ITTYBIT_API_KEY"

# Response when complete:
# {
#   "id": "task_xyz789",
#   "status": "completed",
#   "output": {
#     "url": "https://cdn.ittybit.com/abc123/photo-800w.webp"
#   }
# }

Worker: cache-first image optimization

This is the full Worker. It builds a cache key from the image ID and query params, checks the Cache API, and on a miss dispatches an Ittybit task, polls for the result, stores the output in R2, and caches it.

export interface Env {
  ITTYBIT_API_KEY: string;
  IMAGES_BUCKET: R2Bucket;
}

const ALLOWED_FORMATS = new Set(['webp', 'avif', 'jpeg']);
const MAX_WIDTH = 2048;

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const match = url.pathname.match(/^\/images\/([a-zA-Z0-9_-]+)$/);
    if (!match) {
      return new Response('Not found', { status: 404 });
    }

    const imageId = match[1];
    const width = Math.min(parseInt(url.searchParams.get('w') || '0'), MAX_WIDTH) || undefined;
    const format = url.searchParams.get('f') || 'webp';
    const quality = url.searchParams.get('q') || 'high';

    if (!ALLOWED_FORMATS.has(format)) {
      return new Response('Unsupported format', { status: 400 });
    }

    // Build a stable cache key from the transform parameters
    const cacheKey = new Request(
      `${url.origin}/images/${imageId}?f=${format}&q=${quality}${width ? `&w=${width}` : ''}`,
      request,
    );

    // 1. Check Cache API
    const cache = caches.default;
    const cached = await cache.match(cacheKey);
    if (cached) return cached;

    // 2. Check R2 for a previously processed variant
    const r2Key = `variants/${imageId}/${width || 'orig'}-${quality}.${format}`;
    const r2Object = await env.IMAGES_BUCKET.get(r2Key);

    if (r2Object) {
      const response = new Response(r2Object.body, {
        headers: {
          'Content-Type': r2Object.httpMetadata?.contentType || `image/${format}`,
          'Cache-Control': 'public, max-age=31536000, immutable',
        },
      });
      // Backfill the edge cache
      await cache.put(cacheKey, response.clone());
      return response;
    }

    // 3. Cache miss -- dispatch an Ittybit task
    const taskRes = await fetch('https://api.ittybit.com/jobs', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${env.ITTYBIT_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        input: `https://cdn.example.com/originals/${imageId}.jpg`,
        kind: 'image',
        options: {
          ...(width && { width }),
          format,
          quality,
        },
      }),
    });

    if (!taskRes.ok) {
      return new Response('Failed to create task', { status: 502 });
    }

    const task = await taskRes.json<{ id: string }>();

    // 4. Poll until complete
    const result = await pollTask(task.id, env.ITTYBIT_API_KEY);
    if (!result.output?.url) {
      return new Response('Processing failed', { status: 502 });
    }

    // 5. Fetch the processed image and store in R2
    const imageRes = await fetch(result.output.url);
    const imageBytes = await imageRes.arrayBuffer();

    await env.IMAGES_BUCKET.put(r2Key, imageBytes, {
      httpMetadata: { contentType: `image/${format}` },
    });

    // 6. Return and cache
    const response = new Response(imageBytes, {
      headers: {
        'Content-Type': `image/${format}`,
        'Cache-Control': 'public, max-age=31536000, immutable',
      },
    });
    await cache.put(cacheKey, response.clone());
    return response;
  },
} satisfies ExportedHandler<Env>;

async function pollTask(taskId: string, apiKey: string) {
  for (let i = 0; i < 30; i++) {
    const res = await fetch(`https://api.ittybit.com/jobs/${taskId}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const task = await res.json<{
      status: string;
      output?: { url: string };
    }>();

    if (task.status === 'completed') return task;
    if (task.status === 'failed') throw new Error(`Task ${taskId} failed`);

    await new Promise((r) => setTimeout(r, 1000));
  }
  throw new Error('Task timed out');
}

Requests flow through three layers: Cache API (edge), R2 (persistent), Ittybit (origin). After the first request for a given variant, every subsequent hit resolves at the edge with zero origin traffic.

Wrangler config

name = "image-optimizer"
main = "src/index.ts"

[[r2_buckets]]
binding = "IMAGES_BUCKET"
bucket_name = "optimized-images"

[vars]
# Set via: wrangler secret put ITTYBIT_API_KEY

Request examples

Once deployed, request transformed images with query parameters:

# Resize to 800px wide, WebP
curl https://images.example.com/images/photo-abc123?w=800&f=webp

# AVIF at medium quality
curl https://images.example.com/images/photo-abc123?w=400&f=avif&q=medium

# Full-size JPEG
curl https://images.example.com/images/photo-abc123?f=jpeg

Format selection

Pick a format based on your audience:

FormatUse when
webpDefault — broad support, 25-35% smaller than JPEG
avifBandwidth-constrained users, modern browsers only
jpegMaximum compatibility, legacy clients

See also