← Back to Guides
9 min readIntermediate
Share

Handling File Uploads Without Melting Your Server

Why the obvious upload route breaks at 5MB, how presigned direct-to-storage uploads work, and the validation you can't skip when the file never touches your backend.

Handling File Uploads Without Melting Your Server

Every upload feature starts the same way:

// app/api/upload/route.ts — works locally, fails in production
export async function POST(req: Request) {
  const form = await req.formData();
  const file = form.get("file") as File;
  const bytes = Buffer.from(await file.arrayBuffer());
  await put(file.name, bytes);
  return Response.json({ ok: true });
}

This works perfectly on your laptop with a 200KB test image. Then someone uploads a 40MB video and you discover three things at once:

  • Serverless platforms cap request body size — Vercel's is 4.5MB for a function invocation, and it's not configurable.
  • await file.arrayBuffer() pulls the entire file into memory. Two concurrent 30MB uploads on a 512MB function is a hard OOM.
  • The upload occupies your function for its entire duration. A user on hotel wifi holds a compute instance hostage for ninety seconds.

The whole problem is that the bytes are routing through your backend. They don't need to.

The shape that actually works

Direct-to-storage uploads with a presigned URL. Three steps:

  1. Client asks your server for permission, sending only metadata: filename, size, content type.
  2. Server authorizes and returns a short-lived signed URL pointing at your storage bucket. No bytes involved — this request is a few hundred bytes and takes milliseconds.
  3. Client uploads straight to storage. Your server never sees the file.

Your backend stays a fast metadata service. Storage does what storage is good at. The 4.5MB limit stops being relevant because the file never crosses your function.

Step 1: the authorization route

This is the only place your code runs, so it's where all the policy lives:

// app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from "@vercel/blob/client";
import { auth } from "@/lib/auth";

const MAX_BYTES = 25 * 1024 * 1024;
const ALLOWED = ["image/jpeg", "image/png", "image/webp", "application/pdf"];

export async function POST(request: Request) {
  const body = (await request.json()) as HandleUploadBody;

  try {
    const result = await handleUpload({
      body,
      request,
      onBeforeGenerateToken: async (pathname) => {
        const session = await auth();
        if (!session) throw new Error("Not signed in");

        return {
          allowedContentTypes: ALLOWED,
          maximumSizeInBytes: MAX_BYTES,
          // Namespace by user so one account can't overwrite another's files.
          pathname: `u/${session.userId}/${crypto.randomUUID()}-${sanitize(pathname)}`,
          tokenPayload: JSON.stringify({ userId: session.userId }),
        };
      },
      onUploadCompleted: async ({ blob, tokenPayload }) => {
        const { userId } = JSON.parse(tokenPayload ?? "{}");
        await db.files.create({ url: blob.url, userId, size: blob.size });
      },
    });

    return Response.json(result);
  } catch (err) {
    return Response.json({ error: (err as Error).message }, { status: 400 });
  }
}

The equivalent with S3 is getSignedUrl from @aws-sdk/s3-request-presigner with a PutObjectCommand; the structure is identical, you just build the URL yourself and record the row in a separate callback or a bucket event.

Three things in there matter more than they look:

maximumSizeInBytes is enforced by the storage provider, not by you. This is the entire point. A client that lies about the size in step 1 gets rejected by the bucket in step 3. Client-side size checks are a courtesy to honest users; this is the actual limit.

onUploadCompleted fires from storage, not from the browser. Don't write the database row on the client's "upload finished" callback — a client that closes the tab, or a malicious one that simply doesn't call it, leaves you with an orphaned file and no record. Storage telling you is the only trustworthy signal.

The pathname is generated server-side. Which brings us to the part people get wrong.

Never trust the filename

A user-supplied filename is untrusted input that you are about to use as a path. Treat it accordingly:

function sanitize(name: string): string {
  return name
    .split(/[/\\]/).pop()!          // strip any directory component
    .replace(/[^a-zA-Z0-9._-]/g, "-")
    .replace(/^\.+/, "")            // no leading dots: ".htaccess", "..%2F"
    .slice(0, 100) || "file";
}

The attacks this blocks are old and still work against fresh code:

  • ../../../etc/passwd — path traversal, if you're writing to a filesystem
  • avatar.png.html — a file the browser will happily render as HTML if you serve it with a guessed content type
  • A 4,000-character filename that blows up a database column or a filesystem limit

Prefixing with a UUID, as the route above does, makes collisions and guessing impossible too. Keep the original name in a database column if you need to show it — just never let it decide where bytes land.

Content type is a claim, not a fact

file.type in the browser comes from the OS's extension mapping. Renaming payload.html to photo.png gives you a File object claiming to be image/png. The extension is a suggestion; so is the header.

If it matters — and it matters any time you'll serve the file back to other users — check the magic bytes:

const SIGNATURES: [string, number[]][] = [
  ["image/jpeg", [0xff, 0xd8, 0xff]],
  ["image/png", [0x89, 0x50, 0x4e, 0x47]],
  ["application/pdf", [0x25, 0x50, 0x44, 0x46]],
  ["image/gif", [0x47, 0x49, 0x46, 0x38]],
];

/** Read the first bytes of a File and return the type it actually is. */
export async function sniffType(file: File): Promise<string | null> {
  const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
  for (const [type, sig] of SIGNATURES) {
    if (sig.every((b, i) => head[i] === b)) return type;
  }
  // WebP is "RIFF" + 4 size bytes + "WEBP".
  const ascii = (start: number, len: number) =>
    String.fromCharCode(...head.slice(start, start + len));
  if (ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP") return "image/webp";
  return null;
}

file.slice(0, 12) reads twelve bytes, not the whole file — cheap enough to run on every selection.

Doing this in the browser catches honest mistakes and gives instant feedback. It does not secure anything, because the attacker controls the browser. The real defences are server-side and non-negotiable:

  • Serve user files from a different origin than your app (a bucket domain, not yourapp.com/uploads), so a stored HTML file can't touch your cookies or localStorage.
  • Set Content-Disposition: attachment for anything you don't explicitly render.
  • Set an explicit Content-Type from your allowlist when serving, and send X-Content-Type-Options: nosniff.

That combination means even a successfully uploaded malicious file is inert.

Progress, cancellation, and the fetch problem

fetch still can't report upload progress. If you want a real progress bar, XMLHttpRequest is the boring answer that works everywhere:

export function uploadWithProgress(
  url: string,
  file: File,
  onProgress: (pct: number) => void,
  signal?: AbortSignal
): Promise<void> {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("PUT", url);
    xhr.setRequestHeader("Content-Type", file.type);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
    };
    xhr.onload = () => (xhr.status < 400 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`)));
    xhr.onerror = () => reject(new Error("Network error during upload"));
    signal?.addEventListener("abort", () => xhr.abort(), { once: true });

    xhr.send(file);
  });
}

Yes, XMLHttpRequest in 2026. It's forty lines, has no dependencies, and does the one thing fetch doesn't. Reaching for a 30KB upload library to avoid it is the trade going the wrong way.

Wire the AbortSignal up to a cancel button and to component unmount — a user who navigates away mid-upload shouldn't keep pushing 20MB. Same discipline as cancelling LLM streams.

Shrink it before it leaves

The fastest upload is a smaller file. For images, a canvas re-encode in the browser routinely cuts a phone photo from 4MB to 300KB with no visible difference:

async function shrink(file: File, maxDim = 2000, quality = 0.82): Promise<Blob> {
  const bitmap = await createImageBitmap(file);
  const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
  if (scale === 1 && file.size < 500_000) return file;   // already small enough

  const canvas = document.createElement("canvas");
  canvas.width = Math.round(bitmap.width * scale);
  canvas.height = Math.round(bitmap.height * scale);
  canvas.getContext("2d")!.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
  bitmap.close();

  return new Promise((res) => canvas.toBlob((b) => res(b!), "image/webp", quality));
}

The image resizer project is this same idea with a UI on top, if you want to see it running before you wire it in.

One caveat: canvas re-encoding strips EXIF, including orientation. Most browsers apply orientation when decoding to a bitmap, so the output is usually upright — but test with a photo taken in portrait on an actual phone before you trust it. It also strips GPS coordinates, which is a privacy feature you can advertise.

Checklist

  • Presigned direct-to-storage upload; your function handles metadata only
  • Size and content-type limits enforced by the storage provider, not the client
  • Server-generated pathnames, user filenames sanitized and stored separately
  • Files served from a separate origin, with explicit content type and nosniff
  • Database row written from the storage completion callback, not the browser
  • XMLHttpRequest for progress, AbortSignal for cancel
  • Client-side image shrink before upload

The theme running through all of it: the bytes are the storage provider's problem, and the policy is yours.

Related: Security for vibecoded apps · Working with APIs · Caching strategies for vibecoded apps

Get the good stuff

New tools and posts, occasionally. No spam.