← The blog
Backend·Oct 2025·7 min read

S3 attachments done right: signed uploads and cleanup

Browsers upload straight to S3 with a presigned URL, and a nightly sweep deletes objects no row points to.

Routing a 40 MB file upload through your API server is a waste of bandwidth and a memory risk. The browser should send the bytes straight to S3, and the API should only ever touch a short signed URL. Done right, that also leaves you with the orphan problem, which a nightly sweep handles.

Presigned uploads

The API generates a presigned PUT URL scoped to one object key, with a short expiry and a content-type constraint. The browser uploads directly to S3 with that URL. Your server never holds the file.

const key = `uploads/${userId}/${crypto.randomUUID()}`;
const url = await getSignedUrl(s3, new PutObjectCommand({
  Bucket: BUCKET, Key: key, ContentType: contentType,
}), { expiresIn: 300 });

The five-minute expiry is deliberate. The URL is enough to upload one object and then it is dead, so a leaked URL is not a standing liability.

Record the key, then confirm

The flow has two steps. First the client asks for a URL and the server writes a pending attachment row. After the upload succeeds, the client calls back to mark it confirmed. An attachment that never gets confirmed is an orphan waiting to be cleaned up.

// 1. POST /attachments -> { url, key }, status: 'pending'
// 2. client PUTs to S3
// 3. POST /attachments/confirm { key } -> status: 'ready'

Validate on the server, not the client

Content-type from the browser is a hint, not a guarantee. After confirm, I check the actual object metadata and size against what was claimed, and reject anything that does not match. A client that says image/png and uploads a 2 GB blob should not get a ready row.

The cleanup sweep

Orphans accumulate from abandoned uploads, failed confirms, and deleted records whose files were missed. A nightly job lists keys with no matching ready row and deletes them, but only objects older than a day, so an in-progress upload is never swept out from under a user.

for (const obj of await listAll(BUCKET, "uploads/")) {
  const known = await db.attachment.findFirst({ where: { key: obj.Key, status: "ready" } });
  if (!known && ageInHours(obj.LastModified) > 24) {
    await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: obj.Key }));
  }
}

Deletes go through the same door

When a record is deleted, its S3 object is deleted in the same operation, inside a transaction-like flow where the row goes first and the object follows. If the object delete fails, the nightly sweep catches it later, because the row is already gone and the key is now an orphan by definition. The database is the source of truth, S3 follows it, and the sweep reconciles the gap. That is what keeps storage honest and the bill from quietly climbing.

S3UploadsNodeStorage
Let's talk

Let's build
SOMETHING REAL.

Have a system that needs to think, scale, or just finally get shipped? Let's make it happen.

Based in Dhaka, Bangladesh · Available worldwide