DEV Community

Yy Lee
Yy Lee

Posted on

Where the AI Upscaling Step Actually Goes in a Next.js Image Pipeline

Most "add AI upscaling to your app" tutorials do the same thing: drop a fetch() into a component or a route handler, await the result, render it. It works in the demo. Then a real user uploads a photo, the request hangs for eight seconds, your serverless function times out, and the same image gets re-upscaled on every page load because nothing cached it.

The problem isn't the model. It's where you put the call.

Upscaling is a generative job: slow, non-deterministic, and it costs money per image. That profile is nothing like the CRUD calls the rest of your app makes, and it does not belong on the request path. This post is about the pipeline shape: where the step goes, how you cache it, and how it fails gracefully. The upscaler itself is swappable, so I'll keep the actual API call behind one function you can point at any provider.

The short answer: keep it off the request path

Here's the whole pipeline in one line:

upload → hash the input → check cache → if miss, enqueue an async upscale job → store the result → next/image serves the finished file.

End-to-end asynchronous AI image upscaling pipeline from upload through cache and model processing to the finished image.

The user's request never waits on the model. It uploads, gets back a job ID (or just the original image), and the upscaled version appears when it's ready. Same input always maps to the same output, so you upscale each distinct image exactly once.

Three properties make this work, and they're the same three you'd want for any heavy generative step:

  • Async: the upscale runs in a background job, not the HTTP handler.
  • Idempotent: a content hash of the input is the cache key, so retries and duplicate uploads are free.
  • Fallback-first: if the upscale is missing or failed, you serve the original. Nothing 500s because a model was slow.

Step 1: hash the input so you upscale once

The cache key is a hash of the file bytes plus the scale factor. Same photo at 4x always resolves to the same key, whether it came from a retry, a re-upload, or two users with the identical image.

// lib/upscale-key.ts
import { createHash } from "node:crypto";

export function upscaleKey(input: Buffer, scale: 2 | 4): string {
  return createHash("sha256")
    .update(input)
    .update(`@${scale}x`)
    .digest("hex");
}
Enter fullscreen mode Exit fullscreen mode

That hex string is your storage path (upscaled/{key}.webp) and your cache lookup. No database row required to start; object storage listing is enough.

Content hashing routes duplicate images to cache while new inputs are sent to the AI upscaling worker.

Step 2: put the upscale call behind one function

This is the only vendor-specific code in the pipeline, so it's the only thing you'd swap to change providers. Keep the signature boring: bytes in, bytes out.

// lib/upscale.ts
// Swap the body for whichever service you use. Read ITS docs for the
// exact endpoint, auth, and request shape. Don't copy numbers from a blog.
export async function upscaleImage(
  input: Buffer,
  scale: 2 | 4,
): Promise<Buffer> {
  const res = await fetch(process.env.UPSCALE_API_URL!, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.UPSCALE_API_KEY!}`,
      "Content-Type": "application/octet-stream",
      "x-scale": String(scale),
    },
    body: input,
  });

  if (!res.ok) {
    throw new Error(`upscale failed: ${res.status} ${res.statusText}`);
  }
  return Buffer.from(await res.arrayBuffer());
}
Enter fullscreen mode Exit fullscreen mode

I'm deliberately not giving you a real endpoint or a "typical latency of X ms" here, because I haven't benchmarked every provider's API and made-up numbers are worse than none. What matters is the shape: one async function that can fail, wrapped so the rest of the pipeline never assumes it's fast.

Step 3: the upload handler enqueues, it doesn't wait

The route handler does the cheap, synchronous work (hash, check cache, kick off the job) and returns immediately. It never awaits the model.

// app/api/images/route.ts
import { NextResponse } from "next/server";
import { upscaleKey } from "@/lib/upscale-key";
import { getUpscaled, enqueueUpscale } from "@/lib/store";

export async function POST(req: Request) {
  const input = Buffer.from(await req.arrayBuffer());
  const scale = 4 as const;
  const key = upscaleKey(input, scale);

  // Cache hit: the upscaled file already exists.
  const existing = await getUpscaled(key);
  if (existing) {
    return NextResponse.json({ key, status: "ready", url: existing });
  }

  // Cache miss: store the original as the fallback, queue the job, return now.
  const originalUrl = await enqueueUpscale(key, input, scale);
  return NextResponse.json({ key, status: "processing", url: originalUrl });
}
Enter fullscreen mode Exit fullscreen mode

The client renders url right away. On a miss that's the original image: visibly fine, just not yet sharpened. It swaps to the upscaled version once the job lands, either by polling GET /api/images/{key} or via whatever realtime channel you already have.

The background worker is where the slow call actually happens:

// worker/upscale-job.ts
import { upscaleImage } from "@/lib/upscale";
import { putUpscaled, markFailed } from "@/lib/store";

export async function runUpscaleJob(key: string, input: Buffer, scale: 2 | 4) {
  try {
    const out = await upscaleImage(input, scale);
    await putUpscaled(key, out); // now the next request is a cache hit
  } catch (err) {
    // Fallback stays in place: the original keeps serving. Log and move on.
    await markFailed(key, String(err));
  }
}
Enter fullscreen mode Exit fullscreen mode

If the job throws, nobody sees an error page. The original image is still the served fallback; you just didn't get the sharpened one, and your logs tell you why.

Step 4: let next/image do the delivery

Once the upscaled file is in storage, it's a normal image. Don't hand-roll delivery. next/image already does responsive sizing, lazy loading, and format negotiation:

import Image from "next/image";

export function Photo({ src, alt }: { src: string; alt: string }) {
  return (
    <Image
      src={src}          // cache-hit URL, or the original fallback
      alt={alt}
      width={1600}
      height={1200}
      sizes="(max-width: 768px) 100vw, 800px"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Upscaling gives you a bigger, cleaner source; next/image shrinks it back down to whatever the layout needs. Upscaling to 4x and then serving a 400px thumbnail is wasted spend, so match the scale factor to the largest size the image is actually displayed at.

The part the landing pages don't tell you: upscaling invents detail

AI super-resolution transforms a pixelated source into a sharper image while inventing plausible fine details.

This is the caveat worth designing around. An AI upscaler does not recover lost detail. There's no lost detail to recover in a small image; the information isn't there. Super-resolution models generate new pixels that are plausible given what they were trained on. That's hallucinated detail, and it's why an upscaled face can look subtly wrong and an upscaled logo can come back with invented letterforms.

Practical consequence: gate what you send. Upscaling shines on images that are already decent but slightly soft. It struggles on tiny thumbnails and heavily JPEG-compressed inputs, where it confidently invents the wrong thing. A cheap size/format check before you enqueue saves both money and bad output:

// lib/should-upscale.ts
export function shouldUpscale(bytes: number, width: number): boolean {
  const tooSmall = width < 256;          // not enough signal; it'll fabricate
  const tooLarge = bytes > 10 * 1024 * 1024; // common provider ceiling: ~10MB
  return !tooSmall && !tooLarge;
}
Enter fullscreen mode Exit fullscreen mode

Which upscaler?

The pipeline above is vendor-neutral. Anything that takes an image and returns a bigger one drops into upscaleImage(). For prototyping I've been using Imagvio's AI image upscaler, which does 2x/4x (and advertises up to 8x) on JPG/PNG/WEBP.

Full disclosure: it's an independent third-party tool, not an OpenAI or Google product, and I'm the person building on it, so treat the free tier as an evaluation surface, not a production SLA. It's fine for confirming the pipeline end-to-end and eyeballing output quality on your real images; check its own terms before you route production volume through anything, and confirm request details against the provider's docs rather than this post.

What to measure (instead of trusting a number in a blog post)

I'm not going to quote you a latency figure, because it depends on the provider, the scale factor, the input size, and the day. Measure it yourself with your own images:

  • Per-image latency at each scale. Time upscaleImage() directly. This sets how long "processing" is visible to users.
  • Cache hit rate. If it's low, your hash key is wrong (are you hashing decoded pixels vs. raw bytes inconsistently?) or you're upscaling near-duplicates.
  • Cost per upscaled image × expected distinct images. Distinct, because caching means you pay once per unique input, not once per view.
  • Quality on YOUR inputs. Run ten representative images through at 2x and 4x and look. The right scale is the smallest one that looks good, not the biggest the API offers.

Recap

  • Upscaling is a heavy generative step, so keep it off the request path.
  • Hash the input for an idempotent cache key; upscale each distinct image once.
  • Hide the vendor behind one upscaleImage() function so it's swappable.
  • The upload handler enqueues and returns; a background job does the slow call.
  • Always keep the original as a fallback so a failed job never breaks the page.
  • Let next/image handle delivery.
  • Remember it invents detail, so gate tiny and over-compressed inputs.

I kept the storage/queue layer (lib/store) abstract on purpose, since that's where your existing infra (S3 + SQS, Vercel Blob + a cron worker, R2 + Queues) plugs in. How are you handling the async side? Curious whether people are reaching for a real queue or just a fire-and-forget worker for this. Drop your setup in the comments.


I build a suite of browser-based image tools, which is where the upscaler I mentioned lives. Full disclosure so you can weigh the recommendation accordingly. The pipeline pattern itself is vendor-neutral and works with whatever service you prefer.

Top comments (0)