# Image optimization with Cloudflare Workers and Ittybit

Build an on-demand image transformation API using Workers, Cache API, and Ittybit

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.

<CodeGroup labels={["TypeScript", "curl"]}>
```typescript
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", ... }
```

```bash
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"
    }
  }'
```

</CodeGroup>

## Poll for completion

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

<CodeGroup labels={["TypeScript", "curl"]}>
```typescript
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));

}
}

````

```bash
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"
#   }
# }
````

</CodeGroup>

## 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.

```typescript
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

```toml
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:

```bash
# 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:

| Format | Use when                                           |
| ------ | -------------------------------------------------- |
| `webp` | Default -- broad support, 25-35% smaller than JPEG |
| `avif` | Bandwidth-constrained users, modern browsers only  |
| `jpeg` | Maximum compatibility, legacy clients              |

## See also

- [Media processing with Cloudflare Workers and R2](/guides/media-processing-with-cloudflare-workers-and-r2) -- full upload-to-webhook pipeline
- [Generate responsive image sizes](/guides/generate-responsive-image-sizes) -- srcset and multi-size patterns
- [Convert image formats](/guides/convert-image-formats) -- format conversion options