A POC Cloudflare Workers + Containers setup for on-demand image transformation with R2-backed caching, deployable as a shared service that any number of consumer apps can call via a service binding.
The goal is to keep an image-processing pipeline fully on Cloudflare (no third-party CDN/image services), share one transformer across many apps, and keep worker memory flat regardless of output size.
Live demo: https://boa-stack-img-transformer-app.boxofapps.workers.dev/
- Sharp inside a Cloudflare Container, fronted by a small Hono server, talking to its worker via a Durable Object Container class.
- A
transform()RPC method on the transformer worker that orchestrates the full pipeline (cache-check → container call → cache-write → response) so consumer apps stay thin. - Caller-owned R2 caching via callbacks — the transformer has no R2 binding of its own. Each consumer app passes its own
cacheRead/cacheWriteclosures, so one transformer can serve many apps with different buckets. - A non-buffering streaming path for R2.put across RPC using
FixedLengthStream. This is the non-obvious bit; see the section below.
┌────────────┐ service binding RPC ┌──────────────────────┐
│ consumer │ ─────── transform() ──────> │ transformer worker │
│ app worker │ │ (Hono + RPC entry) │
│ (R2) │ <── cacheRead / cacheWrite ─│ │
└────────────┘ └─────────┬────────────┘
│
container.fetch()
│
▼
┌──────────────────┐
│ Cloudflare │
│ Container │
│ (Node + sharp) │
└──────────────────┘
.
├── apps/
│ └── app-vue/ # demo consumer (Vue SPA + Worker server)
│ ├── server/index.ts # GET /api/assets/* — calls transformer.transform()
│ ├── server/utils/imgTransformerUtils.ts # preset definitions + URL parsing
│ └── wrangler.jsonc # has R2 binding + service binding to transformer
└── services/
└── boa-img-transformer/ # the transformer service
├── src/index.ts # Worker: transform() RPC method
├── src/schemas.ts # zod schemas + toLengthKnown helper + TransformParams type
├── src/exports.ts # what consumer apps import from "@boxofapps/img-transformer"
├── container_src/server.ts # Node + sharp inside the container
├── Dockerfile # node:22-alpine + pinned pnpm@10
└── wrangler.jsonc # Worker + Container + Durable Object config
R2.put accepts a ReadableStream body but rejects any stream whose length it cannot determine:
TypeError: Provided readable stream must have a known length
(request/response body or readable half of FixedLengthStream)
The container sets Content-Length on its response (it buffers the encoded output internally so it can both report a length and return a proper error status if sharp throws). That length does not survive when the response body is proxied across the Workers RPC boundary back into the consumer worker — workerd hands the consumer a fresh ReadableStream with no length metadata, and R2.put refuses it.
The fix is to pass the length alongside the stream as an explicit argument and have the caller wrap the stream in a FixedLengthStream of that exact size before R2.put. Bytes still flow through chunk-by-chunk; nothing is buffered on the worker side.
The transformer's cacheWrite callback signature is therefore:
cacheWrite: (key: string, data: ReadableStream<Uint8Array>, contentType: string, length: number) => Promise<void>And every consumer's implementation collapses to:
import { toLengthKnown } from "@boxofapps/img-transformer";
cacheWrite: async (key, data, contentType, length) => {
await env.R2.put(key, toLengthKnown(data, length), { httpMetadata: { contentType } });
}toLengthKnown is just stream.pipeThrough(new FixedLengthStream(length)). FixedLengthStream also asserts the byte count at runtime, so a Content-Length mismatch errors loudly instead of silently truncating.
- Memory: without this fix the only alternative is to buffer the encoded output into a
Uint8Arrayinside one of the workers, which costs~2 × output_sizeof worker heap per concurrent request. A 2 MB webp under high concurrency can pressure the 128 MB worker memory limit. - Discoverability: this requirement is not documented on the R2 Workers API reference page or the Streams API page. The runtime error is the only source.
- It used to "just work": for several months the previous design passed
transformResponse.bodystraight through toR2.put, and this succeeded — presumably because workerd's older RPC stream proxy preserved the underlying response's length more eagerly. After a dependency bump (@cloudflare/containers0.2 → 0.3) and a normal redeploy, every cache write started failing. We don't have a clean bisect, but the FixedLengthStream contract is the only one that's stable and documented (in the error message, at least).
For a 1.6 MB source PNG → 160×160 webp thumbnail (~4 KB out):
| Component | Peak memory used |
|---|---|
| Consumer worker | a small handful of bytes (stream chunks only) |
| Transformer worker | a small handful of bytes (stream chunks only) |
| Container | full encoded output buffered before responding (~4 KB) + sharp working set |
| R2 | the persisted ~4 KB |
The container has multi-GB of memory available and runs sharp natively, so it's the right place to buffer. The workers stay flat regardless of output size, which matters for higher-resolution presets (2k, preview4k etc.) that can produce multi-MB encoded output.
Cloudflare Containers in wrangler dev work on macOS/Linux. On Windows you need to run the container's Node server separately and let the worker proxy to
localhost:3000(handled byMODE=developmentinsrc/index.ts).
pnpm installIn one terminal — the transformer worker:
cd services/boa-img-transformer
pnpm dev
# Windows: also run `pnpm dev:container` in another terminalIn another — the demo Vue app:
cd apps/app-vue
pnpm devOpen the app, watch wrangler tail on either side. The Vue page requests /api/assets/img.png?p=thumb160 etc., which hits the transformer.
/api/clear-cache (POST) wipes the cached/ prefix in R2 so the next request goes through the full pipeline again.
The two workers deploy independently. Deploy the transformer first so the consumer's service binding resolves to a worker whose RPC surface is in sync:
cd services/boa-img-transformer && pnpm run deploy
cd ../../apps/app-vue && pnpm run deployYou'll need:
- An R2 bucket for the consumer (its binding name in
wrangler.jsoncisR2, bucket nameboa-stack-img-transformer-app— change as needed). - The service binding name in the consumer's
wrangler.jsoncmust match the transformer'sname(boa-stack-img-transformer-service).
Add the service binding to your wrangler.jsonc:
Type the binding:
import type { BoaImgTransformerWorker } from "@boxofapps/img-transformer";
type Env = {
BOA_IMG_TRANSFORMER: Service<BoaImgTransformerWorker>;
R2: R2Bucket;
// …
};Call it:
import { toLengthKnown } from "@boxofapps/img-transformer";
const original = await env.R2.get(sourceKey);
if (original == null) return new Response("Not found", { status: 404 });
return env.BOA_IMG_TRANSFORMER.transform({
body: original.body,
config: {
transform: { width: 160, height: 160, fit: "contain" },
output: { format: "image/webp" },
},
cacheKey: `cached/${sourceKey}/thumb160`,
cacheRead: async (key) => {
const obj = await env.R2.get(key);
if (obj == null) return null;
return {
body: obj.body,
contentType: obj.httpMetadata?.contentType ?? "image/webp",
size: obj.size,
};
},
cacheWrite: async (key, data, contentType, length) => {
await env.R2.put(key, toLengthKnown(data, length), {
httpMetadata: { contentType },
});
},
});That's the whole integration. The returned Response is the transformed image, ready to send to the browser.
- Container size —
wrangler.jsonc→containers[0].instance_type."basic"for this demo; in production we use a custom shape ({ vcpu: 4, memory_mib: 12288, disk_mb: 4000 }). - Concurrency —
containers[0].max_instances. The worker usesgetRandom(BOA_IMG_TRANSFORMER, 5)to fan out; raise both numbers together if you need higher throughput. - Cold start budget —
sleepAfteron theContainersubclass (currently"2m"). Containers cold-start in 5–10 s on first request after sleep; tune up to keep them warm during traffic spikes. - Sharp limits —
container_src/server.tsuseslimitInputPixels: 50000 * 50000as a basic abuse guard. Tighten for untrusted inputs.
Things that surfaced while building this and don't have a clean answer yet:
-
R2.putlength contract across RPC. There's no obvious way to put a length-known stream into R2 that survives the RPC stream proxy without each caller manually wrapping inFixedLengthStream. The runtime error namesrequest/response body or readable half of FixedLengthStreamas the only accepted shapes; passing aResponse(with Content-Length set) across RPC also loses the length on the receiver side. An optionallengthfield onR2PutOptionswould remove the ceremony. -
Stream metadata across RPC.
Response/Requestbodies don't carry Content-Length across a service-binding hop, which is what forced this design. The behavior isn't really documented either way. -
Container response inversion. To satisfy R2's length requirement the container buffers the encoded output and sets
Content-Length. Streaming directly from sharp to R2 would be lighter but loses the ability to return non-2xx on a sharp failure (the HTTP response is already committed as 200 once the first byte streams). -
Container lifecycle for spiky traffic.
sleepAftercovers the common case; cold starts are 5–10 s. A "keep N warm" knob would help. -
@cloudflare/containersAPI churn. The 0.2 → 0.3 jump appears to have changed how container response streams flow back through the worker. This surfaced as a production regression on the first redeploy carrying the bump, despite no code changes on our side. Pinning the package version is recommended until the API settles. -
R2 bindings can't cross RPC. That's why caching is done via
cacheRead/cacheWritecallbacks rather than the transformer owning the bucket directly. Workable, but extra surface area for every consumer.
MIT — see LICENSE if present, otherwise consider this MIT-equivalent.