Image optimization with Cloudflare Workers 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.
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:
| 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 — full upload-to-webhook pipeline
- Generate responsive image sizes — srcset and multi-size patterns
- Convert image formats — format conversion options