New:Upstash has a remote MCP server
·13 min read

AWS S3 vs Upstash Blob: The Best S3 Alternatives for 2026

JoshJoshDevRel @Upstash
https://upstash.com/blog/aws-s3-vs-upstash-blob

Upstash Blob is the best S3 alternative. You connect with a single token, and it comes with a type-safe upload handler and a built-in CDN. AWS S3 is still a good (but more expensive, numbers below) fit if your team already uses AWS.

In this article I want to run some uploads against an Upstash Blob bucket, once with the Blob SDK and once with the AWS SDK to compare them.

What does it take to upload a file to S3?

Before a browser can upload a single file to S3, you need to set up four AWS pieces and write the signing code yourself:

  1. You create a bucket and pick a region. S3 prices differ by region, so picking a region also means picking a price.
  2. You create an IAM (Identity and Access Management, AWS's permission system) user or role and give it access keys. A presigned URL uses the credentials of whoever signed it, so that IAM user or role needs permissions like s3:PutObject and s3:GetObject.
  3. You check the bucket policy. If it denies access to an object, even a correctly signed URL returns 403 Forbidden.
  4. You add a CORS (cross-origin resource sharing) rule to the bucket so the browser can send a PUT from your domain. If you get it wrong, you end up debugging propagation delays, HTTP vs HTTPS mismatches, and header problems in the browser's network tab.
  5. You install @aws-sdk/s3-request-presigner and write a server route that signs a URL for each upload.
  6. If you want files served from a CDN, you put CloudFront in front of the bucket, since S3 doesn't include one.

And you still have to write your own code for progress bars, retries, and big files.

How much code does Upstash Blob need?

Upstash Blob needs one environment variable and 14 lines to upload, read back, and delete a file. The same job with @aws-sdk/client-s3 takes 36 lines and five environment variables.

The Blob client automatically reads UPSTASH_BLOB_TOKEN from the environment:

import { Bucket } from "@upstash/blob"
 
const bucket = Bucket.fromEnv()
const path = "articles/s3-vs-blob/hello.txt"
 
const uploaded = await bucket.put(path, "Hello from S3 vs Blob!", {
  contentType: "text/plain",
})
console.log(uploaded)
 
try {
  const downloaded = await bucket.get(path)
  const contents = await new Response(downloaded.body).text()
  console.log(contents)
} finally {
  await bucket.del(path)
}

If we run it against a real bucket:

{
  path: 'articles/s3-vs-blob/hello.txt',
  url: 'https://b27353de2921.blob.upstash.io/articles/s3-vs-blob/hello.txt',
  versionedUrl: 'https://b27353de2921.blob.upstash.io/articles/s3-vs-blob/hello.txt?v=%22d80f901a22f40ccd4fcdb663d4703519%22',
  size: 22,
  etag: '"d80f901a22f40ccd4fcdb663d4703519"',
  uploadedAt: 2026-09-29T07:47:16.690Z,
  contentType: 'text/plain'
}
Hello from S3 vs Blob!

The result already includes a public URL for the file and a versioned URL for cache busting, both ready to save in your database.

Here's the same upload, read, and delete with the AWS SDK:

import {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  DeleteObjectCommand,
} from "@aws-sdk/client-s3"
 
const s3 = new S3Client({
  region: process.env.AWS_REGION!,
  endpoint: process.env.AWS_ENDPOINT_URL, // Omit for AWS S3.
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    sessionToken: process.env.AWS_SESSION_TOKEN,
  },
})
 
const object = {
  Bucket: process.env.S3_BUCKET!,
  Key: "articles/s3-vs-blob/hello.txt",
}
const command = new PutObjectCommand({
  ...object,
  Body: "Hello from S3 vs Blob!",
  ContentType: "text/plain",
})
const uploaded = await s3.send(command)
console.log(uploaded)
 
try {
  const command = new GetObjectCommand(object)
  const downloaded = await s3.send(command)
  const contents = await downloaded.Body!.transformToString()
  console.log(contents)
} finally {
  const command = new DeleteObjectCommand(object)
  await s3.send(command)
  s3.destroy()
}

We ran this one against the same Upstash Blob bucket. You can exchange a Blob bucket token for temporary S3 credentials, and the endpoint line points the AWS SDK at Blob instead of AWS. The upload returned the same ETag:

{
  ETag: '"d80f901a22f40ccd4fcdb663d4703519"',
  ChecksumCRC32: 'aXMWww==',
  VersionId: '7e5f13de217beb00b8518956308e2c77',
  '$metadata': {
    httpStatusCode: 200,
    requestId: undefined,
    extendedRequestId: undefined,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  }
}
Hello from S3 vs Blob!

The S3 response has no URL for the file. You build that yourself from the bucket name and region, or from your CloudFront domain.

Because Upstash Blob is AWS-compatible, any scripts, the AWS CLI, and other S3 tools just work™ with a Blob bucket (so migrating on or off is super easy).

How do browser uploads work on each?

On S3, your server signs a URL with @aws-sdk/s3-request-presigner and the browser sends the file to it. You write progress, retries and multipart yourself. With Upstash Blob, you get an upload handler that checks the user and signs the URL, and a React hook that handles progress, retries and multipart for you.

Both send the file from the browser straight to storage. This is important on serverless, because if the file went through your function, it would hit Vercel's 4.5MB or AWS Lambda's 6MB request body limit.

How this looks in code:

import {
  S3Client,
  PutObjectCommand,
  DeleteObjectCommand,
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
 
const s3 = new S3Client({
  region: process.env.AWS_REGION!,
  endpoint: process.env.AWS_ENDPOINT_URL, // not needed for s3
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    sessionToken: process.env.AWS_SESSION_TOKEN,
  },
})
 
const object = {
  Bucket: process.env.S3_BUCKET!,
  Key: "articles/s3-vs-blob/presigned.txt",
}
const body = "Hello from a presigned PUT!"
const command = new PutObjectCommand({
  ...object,
  ContentType: "text/plain",
  Body: body,
})
const url = await getSignedUrl(s3, command, { expiresIn: 60 })
 
try {
  const response = await fetch(url, {
    method: "PUT",
    headers: { "Content-Type": "text/plain" },
    body,
  })
  console.log("PUT status:", response.status)
  if (!response.ok) throw new Error(`PUT failed: ${response.status}`)
} finally {
  const command = new DeleteObjectCommand(object)
  await s3.send(command)
  s3.destroy()
}
PUT status: 200

That's one small file with one signed URL. A real app also needs a route that checks the user, a client that reports progress, retry logic, and multipart code for large files. If you want headers like the content type signed, you also need extra presigner options.

Upstash Blob also sends the file directly, but your route runs before and after the upload:

How an Upstash Blob browser upload works

In Next.js, the browser flow with Upstash Blob takes three files. The upload handler comes first. It runs onBeforeUpload to check the user and pick a path, and onUploadComplete to record the result:

import { BlobError, uniquePath, uploadHandler } from "@upstash/blob"
 
async function authenticate(_request: Request): Promise<{ id: string } | null> {
  // replace with your auth logic
  return null
}
 
const uploads = uploadHandler({
  routes: {
    avatar: {
      constraints: {
        contentTypes: ["image/*"],
        maxSize: "5mb",
      },
      onBeforeUpload: async ({ request, file }) => {
        const user = await authenticate(request)
        if (!user) throw new BlobError("unauthorized")
 
        return { path: uniquePath`avatars/${user.id}/${file.name}` }
      },
      onUploadComplete: ({ path }) => ({ fileId: path }),
    },
  },
})
 
export type { uploads }
export const { GET, POST } = uploads

Next, the hooks, typed from that handler. The client only imports a type, so only client code ships to the browser:

"use client"
 
import { uploadHooks } from "@upstash/blob/react"
import type { uploads } from "../app/api/uploads/route"
 
export const { useUpload } = uploadHooks<typeof uploads>({
  endpoint: "/api/uploads",
})

Last, the component. accept comes from the route's allowed content types, so the file picker matches what the server accepts:

"use client"
 
import { useUpload } from "./lib/uploads"
 
export default function AvatarUpload() {
  const { start, upload, accept } = useUpload("avatar")
 
  return (
    <div>
      <input
        type="file"
        accept={accept}
        onChange={(event) => start({ file: event.target.files?.[0] })}
      />
      {upload && <p>{upload.percent}%</p>}
      {upload?.pending && <progress value={upload.percent} max={100} />}
      {upload?.status === "done" && <p>Saved: {upload.blob.data.fileId}</p>}
      {upload?.status === "error" && <p>{upload.error.message}</p>}
    </div>
  )
}

Large files work with the same code. Above 16 MB, the client switches to multipart automatically:

  • It splits the file into parts and signs each part separately.
  • It uploads four parts of a file at the same time, with at most six requests in flight across the page.
  • It retries a failed part up to eight times, and up to twenty times after a dropped connection.
  • It saves the upload in localStorage, so when the user picks the same file again, the upload continues where it stopped.

On S3, you'd write and test each of those yourself.

Which one has better TypeScript types and a smaller bundle?

Upstash Blob has better types for uploads and a much smaller bundle size. Types flow from the server handler into the React component, and a bundled Blob upload is about 12 KB gzipped, compared to about 83 KB for the S3 client.

In the upload flow above, we never wrote a type for fileId. TypeScript inferred it from what onUploadComplete returns. So when we changed the component to read upload.blob.data.userId and ran tsc, we got:

scratch/AvatarUpload.wrong.tsx(12,65): error TS2339: Property 'userId' does not exist on type '{ fileId: string; }'.

If you rename the field on the server, TypeScript shows you every component that needs to change. With S3, your signing route and your browser code have separate types. You write the request and response shapes twice, or share an interface by hand, and keep them in sync yourself.

The AWS SDK types also mark fields like the object body as optional, which is why the S3 examples above need ! after result.Body.

For bundle size, we bundled a minimal upload with each SDK using esbuild (minified ESM for the browser, gzip level 9):

BundleMinifiedGzipped
Upstash Blob, server put34.7 KB12.3 KB
Upstash Blob, React upload hooks (React not counted)20.9 KB7.6 KB
AWS S3 client, PutObjectCommand262.6 KB83.1 KB
AWS S3 client plus presigner265.5 KB84.1 KB

Gzipped, the S3 client is about 7 times bigger. When we built the same S3 upload for Node instead of the browser, it grew to 561 KB minified. Bundlephobia shows a similar number for the whole package (85.8 KB minified and gzipped), most of it from the shared @smithy/core runtime.

In practice the S3 client only runs on your server, and the browser uses fetch with a presigned URL. But the size still adds up in every serverless function that imports it.

How much does S3 cost compared to Upstash Blob?

Upstash Blob costs $0.02 per GB stored. The first 1 TB of downloads each month is free, then it's $0.02 per GB, and that includes CDN delivery.

S3 Standard costs $0.023 per GB stored and $0.09 per GB downloaded after the first 100 GB, so apps that serve files pay far more on S3.

The list prices side by side:

ProviderStorage per GB-monthEgress per GBWrites per 1MReads per 1MCDN
Upstash Blob$0.02First 1 TB free, then $0.02$4.50$0.30Included
AWS S3 Standard (us-east-1)$0.023$0.09$5.00$0.40CloudFront, billed separately

Egress (the data your users download) decides most bills. Take a document SaaS that stores 200 GB, serves 300 GB a month, and handles 1M uploads and 10M reads. Its monthly bill works out to:

  • Upstash Blob: $4.00 storage + $0 egress (inside the free 1 TB) + $4.50 writes + $3.00 reads = $11.50
  • AWS S3: $4.60 storage + $18.00 egress + $5.00 writes + $4.00 reads = $31.60

S3 costs almost three times more than Blob, before you even add CloudFront for a CDN. For a media app that stores 1 TB and serves 10 TB a month, it'd be $224 on Upstash Blob (9 TB of paid egress) and $944 on S3.

The free tiers also work differently:

  • Upstash Blob has a free plan with 1 GB of storage, 10 GB of bandwidth, 10,000 reads, and 2,000 writes a month, and you don't need a credit card to start
  • AWS gives everyone 100 GB of free egress a month across all services. New accounts since July 15, 2025 get up to $200 in credits that expire after 12 months, and S3 storage draws from those credits.

Upstash Blob also bills storage by your bucket's average size over the month (sampled daily), and charges only for the fraction of a GB. Deleting objects is free!

What about CDN, regions, file size, and access control?

Upstash Blob includes a global CDN, and every bucket is global. On S3 you choose a region and add CloudFront for CDN delivery. Both support public and private files.

Upstash BlobAWS S3
RegionBuckets are globalYou pick one, and prices differ by region
CDNIncluded at the same ratesCloudFront, set up and billed separately
Max object size5 TB50 TB
Public filesPublic buckets give every object its own URLPublic access through a bucket policy
Private filesEvery read goes through a time-limited signed URLPresigned URLs with IAM credentials
Cache headersA cache option on uploadSet Cache-Control metadata yourself
S3 APIYes, a token exchanges for temporary S3 credentialsNative

On Blob, a reader in Singapore and a reader in Frankfurt both get files from a nearby CDN location by default. CDN delivery costs the same as a download from storage, so it's one rate for a popular file.

The S3 API also covers features the Blob SDK doesn't wrap, like byte ranges, conditional GETs and object tagging. You call those with @aws-sdk/client-s3 pointed at your Blob bucket, like in the earlier example.

What are the best S3 alternatives?

For JavaScript and TypeScript apps (especially on serverless), Upstash Blob is the best S3 alternative, because it includes the upload flow, types and CDN that you'd build or add yourself on S3. S3 is still a good fit for teams already on AWS.

AlternativeStorage per GB-monthEgress per GBGreat when
Upstash Blob$0.02First 1 TB free, then $0.02, CDN includedYou want typed browser uploads that just work, with no AWS setup
Vercel Blob$0.023$0.05 on demand, or 1 TB included with Pro plan Flat Rate CDNYou run your entire infra on Vercel and prefer a single charge per month from one provider
Tigris$0.02 ($20 per TB)$0You want S3-compatible storage that copies data across regions on its own
UploadThingPlan-basedPlan-basedYou want a hosted upload service with its own middleware, and the S3 API doesn't matter
AWS S3$0.023$0.09Your team is on AWS and needs IAM rules, storage classes, or objects bigger than 5 TB

S3 is nice for multi-tenant apps, because IAM policies can lock each tenant to its own folder prefix. So you can isolate tenants with the same AWS tools your team already uses.

For everything else in a web app (avatars, user documents, exports, AI-generated files), Upstash Blob gets you to a working upload with one token, is cheaper than AWS and the best way to start.