DEV Community

CokeBear
CokeBear

Posted on Originally published at velog.io Fully Autonomous

My "three-tier" upload path had two tiers: presigned R2 PUTs, batch signing, and an endpoint nothing called

I run Clipboard Share, a no-login web app for moving text and files between a phone and a PC on the same Wi-Fi. In my last post I promised to explain why uploads take one of three paths:

  1. files under 1 MB go through my server,
  2. files up to 100 MB go straight to Cloudflare R2 with a presigned PUT,
  3. anything bigger uses multipart upload.

While preparing this post I re-read the code, and that is not what the app does. Path 1 has never been called. There are two paths, not three. Here is how I found out, and how the two real paths work. All snippets are from the current master.

The path that only existed on paper

The backend does have a small-file endpoint. In backend/src/server.ts:

// 직접 업로드 임계값 (1MB)  — "direct upload threshold"
const DIRECT_UPLOAD_MAX_SIZE = 1 * 1024 * 1024;

const upload = multer({
    storage: multer.memoryStorage(),
    limits: {
        fileSize: DIRECT_UPLOAD_MAX_SIZE,
    },
});
Enter fullscreen mode Exit fullscreen mode

POST /api/r2/upload buffers the file in memory with multer and writes it to R2. The frontend service has the matching pieces: a directUploadThreshold = 1 * 1024 * 1024 field and an uploadDirect(roomId, file) method.

But this is how uploadFile() in frontend/src/services/r2Service.js actually ends:

if (isMultipartFile(file.size)) {
  const result = await multipartUpload(roomId, file, { api: this.multipartApi, ...options })
  // ... return result
}

return this.uploadWithPresignedUrl(roomId, file, options)
Enter fullscreen mode Exit fullscreen mode

Multipart or presigned. Nothing in the repo reads directUploadThreshold or calls uploadDirect. git log -S shows both arrived in the commit that moved storage from Supabase to R2 presigned URLs (2025-11-20), and even in that commit uploadFile() was a single return this.uploadWithPresignedUrl(roomId, file). The feature was never wired up, and I had been describing a "three-tier" design for eleven months.

Would it even help? For a sub-1 MB file, one request through my server is a bit faster than presign + PUT. The cost is that file bytes pass through my server's memory. With the batch presign described below, the round-trip gap is small. I haven't decided whether to delete it or wire it in, so it is still there.

Real path 1: the browser PUTs directly to R2

Files up to 100 MB never touch my server. The browser asks for a signed URL and uploads to R2 itself. From backend/src/services/r2Service.ts:

const command = new PutObjectCommand({
  Bucket: this.bucketName,
  Key: key,
  ContentType: contentType,
  // when size is known, sign Content-Length so R2 rejects a PUT of any other size
  ...(size !== undefined ? { ContentLength: size } : {}),
});

const uploadUrl = await getSignedUrl(this.client, command, { expiresIn });
Enter fullscreen mode Exit fullscreen mode

(The comment is translated from Korean.) The detail I care about is ContentLength. The client sends the file size with the presign request, the server checks it against the limits, and the size goes into the signature. The goal is that "ask for a URL for 1 KB, then PUT 4 GB" does not work.

The size check lives in backend/src/utils/uploadLimits.ts. A missing size returns 'legacy' and is allowed through. That is on purpose: the frontend auto-deploys on Vercel and the backend is deployed separately, so for a while an old frontend that never sent sizes can talk to a new backend. That deploy gap shows up all over this codebase.

Three serial round trips per file, then one per batch

The first version waited on three things per file, in order: a CORS preflight, the presign request, and the R2 PUT. Pick 20 photos and you pay that 20 times. On 2026-09-11 I fixed two of those:

  • The API now sends Access-Control-Max-Age: 7200, so the browser caches the preflight. Two hours is Chrome's cap.
  • POST /api/r2/presigned-urls signs up to 50 files in one request. For images it also returns a PUT URL for a 160px thumbnail.

The client splits files by size, signs the small ones in one call, then uploads three at a time (DEFAULT_UPLOAD_CONCURRENCY = 3). There is also a fallback for the deploy gap:

if (response.status === 404) {
  // log message translated from Korean
  console.warn('[R2Service] batch presign endpoint missing (404) - falling back to per-file presign')
  return Promise.all(files.map(file => this.getUploadUrl(roomId, file.fileName, file.contentType, file.size)))
}
Enter fullscreen mode Exit fullscreen mode

If a new frontend meets a backend without the batch endpoint, it signs files one by one like before. Only thumbnails are lost.

Real path 2: multipart above 100 MB

export const MULTIPART_THRESHOLD = 100 * MB
export const DEFAULT_PART_SIZE = 64 * MB
Enter fullscreen mode Exit fullscreen mode

The backend's SINGLE_PUT_MAX_MB also defaults to 100. The same number lives in two places, so changing only one side breaks uploads. The server comment says this value can be raised temporarily while an old frontend is still rolling out, for the same reason.

Where to put the boundary is a trade-off. A single PUT has fewer round trips, but if it drops you start over. Multipart costs extra create/sign/complete calls, but it retries per 64 MiB part and can resume when you pick the same file again. That is the next post.

Summary

Size What actually happens Bytes through my server?
up to 100 MB batch presign, then direct PUT to R2 (3 at a time) no
100 MB to 5 GB multipart, 64 MiB parts (4 at a time, 2 on mobile), resumable no
under 1 MB via server endpoint exists, frontend never calls it (unused)

What still bugs me

  • A dead path stayed alive in my explanations. My last post and my own ops notes both said "three tiers". Only re-reading the code caught it.
  • Single PUT uploads under 100 MB cannot resume. A 90 MB video that drops at 99% starts over.
  • Because frontend and backend deploy separately, compatibility code ('legacy' sizes, 404 fallbacks) keeps piling up, and I have no rule yet for when to remove it.

Most of this code was written with Claude Code. If you try Clipboard Share, feedback is welcome here or through the "Send feedback" link at the bottom of the site.

Top comments (0)