DEV Community

彭勇
彭勇

Posted on

I built a free image upscaler on Cloudflare Workers — here's what bit me

I kept running into the same annoyance: screenshots that arrive looking like LEGO, photos that get crushed by messaging apps, and every "fix" behind a signup wall or a subscription. So I built unpixelate.net — upload a pixelated or blurry image, get a sharper one back in seconds. No account, no credit card.

This post isn't a product tour. It's the stuff that actually cost me debugging time: an AI inference API with three hidden traps, a math bug that would have made my own marketing a lie, and a storage quirk where a legal promise ("we delete your images") can silently die while everything returns HTTP 200.

The stack

  • Next.js 16 (App Router) on Cloudflare Workers via @opennextjs/cloudflare
  • R2 for image storage (zero egress fees — this matters later)
  • D1 for billing state, because credits need transactions
  • Replicate for inference: prunaai/p-image-upscale as the workhorse, Topaz's image-upscale as the paid HD engine
  • Zero runtime dependencies in the integration layer — the Replicate client and the AWS SigV4 implementation are both hand-rolled on fetch and node:crypto

Why no SDK for Replicate? The client is ~200 lines, and I wanted every header and timeout visible in code review rather than buried in a dependency. This turned out to be the right call, for reasons you'll see.

Trap #1: three things the Replicate docs make non-obvious

A naive client "works" in testing and then fails in production in three separate ways.

1. POST /v1/predictions has no model field. The owner/name shorthand only works for official Replicate models, through a different endpoint. For community models you must send the full 64-character version id. Pin it, and expose a resolveLatestVersion() helper so drift is visible instead of silent:

const created = await fetch("https://api.replicate.com/v1/predictions", {
  method: "POST",
  headers: {
    authorization: `Bearer ${token}`,
    "content-type": "application/json",
    // Blocks for up to 60s server-side — usually returns a finished
    // prediction and the polling loop never runs.
    prefer: "wait=60",
    // Bounds what we're billed for if this process dies mid-prediction.
    // The API enforces a 5s floor on this header.
    "cancel-after": "300s",
  },
  body: JSON.stringify({ version, input }), // version, NOT model
});
Enter fullscreen mode Exit fullscreen mode

2. Output URLs require your auth header. Results are served from replicate.delivery, and a bare fetch(outputUrl) can 401. Every tutorial forgets this. You must send the same bearer token when downloading the result.

3. Predictions are deleted after about an hour. Inputs, outputs, logs — gone. You cannot store the output URL and fetch it later from a queue consumer. The bytes must be copied somewhere durable during the same request that polls them:

// After the prediction succeeds, immediately pull the bytes down
// with a hard ceiling, then upload to R2 ourselves.
const res = await fetch(outputUrl, {
  headers: { authorization: `Bearer ${token}` },
  signal,
});
const chunks = [];
let size = 0;
for await (const chunk of res.body) {
  size += chunk.length;
  if (size > MAX_DOWNLOAD_BYTES) throw new Error("output over ceiling");
  chunks.push(chunk);
}
Enter fullscreen mode Exit fullscreen mode

That ceiling matters on Workers, where memory is capped and a runaway download shouldn't take the isolate down with it.

Trap #2: the 4-megapixel promise

The marketing page says "up to 4 MP output." Sounds like the model's job, right? Ask for 4 megapixels, ship it.

Wrong. The model has two upscale modes and neither does what the label says. I measured against the live API:

source mode asked got actual MP ratio
1400x1050 factor 1.65 2308x1732 3.997 0.9987
1600x900 factor 1.65 2640x1484 3.918 0.9994
1400x1050 target 4 4.189 1.0473
1600x900 target 3 3.144 1.0480

target mode overshoots by ~4.8%, and its schema is an integer, so you can't trim it with a fractional value — the API rejects that with a 422. factor mode lands within 0.2% of what you asked for. So every viable plan uses factor, computed backwards from the budget:

// Aim 1% under the cap. The model rounds output dimensions, so asking
// for exactly 4 MP can land a fraction ABOVE it — which would make the
// advertised "up to 4 MP" claim false. The headroom is free.
const TARGET_MARGIN = 0.99;

export function planUpscale({ sourceMp, maxOutputMp = 4 }) {
  const budget = maxOutputMp * TARGET_MARGIN;
  const ideal = Math.sqrt(budget / sourceMp);
  const factor = Math.min(8, Math.max(1, Number(ideal.toFixed(2))));
  return { mode: "factor", factor };
}
Enter fullscreen mode Exit fullscreen mode

There's a second, quieter decision in there: if the source is already at or above the cap, we reject before spending the user's credit instead of silently handing back a smaller image than they uploaded. "Your input is too big" is a better answer than a silent downgrade.

The pixel art exception

One more wrinkle in that planner. For most images, inventing detail is the product. For pixel art, inventing detail is always wrong — the art is a grid, and the only correct result is that same grid, larger. But the cap-fitting math happily asks for ~8x on a small sprite, and past roughly 8x the model starts hallucinating pixels.

So callers that know the input is pixel art pass an explicit factor: 4 and get exactly 4x. Small special case, but it's the difference between "this tool gets pixel art" and "this tool ruins pixel art" in a screenshot.

Why everything is PNG (and why R2 makes that free)

The default output format is PNG, deliberately. The model synthesizes pixels; re-encoding those to JPEG throws away detail we just paid for. On most hosts, storing larger files also means paying egress every time someone downloads a result — but R2 charges zero egress, so the bigger file costs storage only. This is one of those architecture decisions that's only obvious in retrospect: the storage pricing shaped the image quality policy.

Trap #3: the privacy promise that dies at HTTP 200

The privacy policy promises uploads and results are deleted after at most 14 days. Enforcing that is a cron that sweeps two R2 prefixes (in/ and out/). Boring code — except for one landmine:

S3's DeleteObjects answers HTTP 200 even when individual keys fail to delete. If you only check the status code, the cron logs "success" every night while a growing pile of expired images survives forever. A legal promise, silently broken, with nothing in the logs.

So the retention function collects every per-key failure instead of throwing on the first one, and the callers decide how to escalate:

// Returns { removed, failed } — never trust the HTTP status alone.
const { removed, failed } = await runRetention({ client, days: 14 });
if (failed.length > 0) process.exitCode = 1; // cron invocation shows as failed
Enter fullscreen mode Exit fullscreen mode

I also merged the prefix list into a single module after noticing the local prune script and the Worker cron each held their own copy. That's the exact shape of bug a legal promise dies from: add a third storage prefix, update one caller, and the other silently stops covering it — with no error anywhere.

What I'd tell my past self

  1. Pin model versions and log version drift. "Latest" is not an API contract.
  2. Measure what the model actually returns, not what the schema says. The table above is 15 minutes of curl that saved the honesty of the landing page.
  3. Anytime a legal promise ("we delete after N days") depends on a cron, simulate a partial failure and check that someone notices.
  4. Zero-dependency integration modules are testable against live APIs with no mocking layer, and on Workers, small is a feature.

Try it

The site is live at unpixelate.net — free to start, no signup, JPG/PNG/WEBP. One honest caveat that's on the site too: the AI estimates missing detail. It's a reconstruction, not a forensic restore — a heavily pixelated face won't come back looking exactly like the real person. For screenshots, compressed photos, and text that went soft, though, the difference is night and day.

If you've shipped AI features on Workers, I'd love to hear what bit you — especially around long-running inference on a platform built for short requests. My current answer is Prefer: wait plus a bounded poll, but I don't love it.

Top comments (0)