S3 vs GCS vs Azure Blob for Media Uploads

For direct browser uploads of media the three are functionally interchangeable: pick the one your workload already lives next to, because the differences that matter are the chunking model, the part-count caps, and the egress bill — not the feature list.

This comparison sits within direct-to-cloud upload patterns under backend validation and cloud storage architecture, and assumes you have already decided that bytes go from the browser straight to storage rather than through your API.

When to use this comparison

  • You are starting a media pipeline and have not committed to a provider yet.
  • You run more than one cloud and need to know exactly where the upload code has to branch.
  • You are sizing a large-file uploader and need the real part-count and chunk-size ceilings before you pick a chunk size.

If you have already committed to one provider, the per-provider walkthroughs are more useful than this page: generating secure presigned URLs with AWS SDK v3, uploading to GCS with the Node.js client libraries, and uploading to Azure Blob with the Storage JS SDK.

Prerequisites

  1. Node 20+ and one or more of @aws-sdk/client-s3 3.x with @aws-sdk/s3-request-presigner, @google-cloud/storage 7.x, @azure/storage-blob 12.x.
  2. Credentials on the signing host: an IAM role with s3:PutObject on arn:aws:s3:::your-bucket/uploads/*, a GCP service account with roles/storage.objectCreator plus iam.serviceAccounts.signBlob, and an Azure storage account key or a user delegation key.
  3. CORS already allowing PUT from your origin on each bucket or storage account — see configuring CORS for GCS and Azure Blob uploads.

The decision at a glance

Dimension Amazon S3 Google Cloud Storage Azure Blob
Signed single write getSignedUrl + PutObjectCommand file.getSignedUrl({ action: "write" }) SAS query string on the blob URL
Browser POST form createPresignedPost policy generateSignedPostPolicyV4 no equivalent — PUT only
Chunked model multipart upload (parts + ETags) resumable session URI (byte offsets) block blob (stageBlock + commitBlockList)
Chunk identity server-assigned ETag per part none — the server owns the offset base64 block ID you choose
Parallel chunks yes no, strictly sequential yes
CORS scope per bucket per bucket per storage account
Request Content-Type pinned by the credential yes yes no
Primary SDK @aws-sdk/client-s3 @google-cloud/storage @azure/storage-blob

How the three signing models differ

All three hand the browser a URL that already carries proof of authorisation, so no credential ever reaches the client. The query parameters differ in name far more than in substance.

Anatomy of the three signed upload credentials Three URL rows broken into endpoint, identity, expiry and signature segments, showing that S3, GCS and Azure encode the same four facts under different parameter names. Anatomy of the three signed upload credentials endpoint identity expiry signature S3 (SigV4) bucket.s3…/key X-Amz-Credential X-Amz-Expires X-Amz-Signature One URL, one key, one verb. Content-Type is part of the signature. GCS (V4) storage.googleapis X-Goog-Credential X-Goog-Expires X-Goog-Signature Same layout, different prefix; the resumable POST is signed the same way. Azure SAS acct.blob…/blob sp=cw & sr=b se=2026-07-26T… sig= (HMAC-SHA256) sp is the permission set and sr=b scopes it to one blob; the verb is not pinned. Expiry lives inside the signature on all three — you cannot extend a credential after issuing it.
The same four facts — endpoint, identity, expiry, signature — under three sets of parameter names.

The one asymmetry worth planning around is the last row of the table above. An S3 presigned PUT and a GCS V4 signed URL both fold Content-Type into the signed headers, so a browser that sends image/jpg against a URL signed for image/jpeg gets rejected by storage. An Azure service SAS has no equivalent: sp=cw grants create-and-write on that blob and the client may declare any content type it likes. If you need Azure to reject a mislabelled upload you have to check the blob’s Content-Type after the fact, which is one more reason to run server-side validation on the object rather than trusting the ticket.

S3 also has a second shape that the other two only partly match — the browser POST policy, which can enforce a content-length range and a key prefix. If size enforcement at the edge matters to you, read presigned POST vs presigned PUT for browser uploads before choosing. GCS mirrors it with generateSignedPostPolicyV4; Azure has nothing equivalent, so on Azure a size cap has to be enforced by your API before you mint the SAS, or by a policy check afterwards.

Chunked and large-file models

This is where the three genuinely diverge, and it dictates the shape of your frontend resumable upload state machine.

Chunked upload models compared S3 uploads parallel parts identified by ETags, GCS streams byte ranges to one resumable URI, Azure stages blocks by ID then commits a list. S3 multipart part 1 part 2 parallel, any order server returns an ETag per part complete with the ordered part list GCS resumable one session URI strictly sequential byte ranges, sized in multiples of 256 KiB the server owns the write offset Azure block blob block A block B parallel, any order you choose the base64 block IDs commitBlockList assembles the blob
Who owns chunk identity is the real difference: the S3 server, nobody, or your client.

S3 multipart splits the file into parts, uploads each independently, and finalises by sending the ordered list of part numbers with the ETags S3 returned. Because the identity of a part comes back in a response header, you must add ETag to ExposeHeaders in the bucket CORS document or the browser reads null and every completion fails.

GCS resumable opens one session URI and the client PUTs sequential byte ranges to it. There is no per-chunk identifier, so nothing needs exposing through CORS, but you also lose parallelism: chunk n+1 cannot start until n is acknowledged. Every chunk except the last must be a multiple of 262,144 bytes.

Azure block blobs let you stageBlock chunks under base64 block IDs you assign, in any order, then commitBlockList to assemble them. It is S3-like parallelism with client-owned identity, which makes retries trivial — restaging a block ID overwrites it — at the cost of you having to generate stable, fixed-width IDs.

// GCS: one resumable session URI is the single endpoint for every chunk.
import { Storage } from "@google-cloud/storage";

const storage = new Storage();

export async function gcsResumableUri(bucket: string, name: string, contentType: string) {
  const [uri] = await storage
    .bucket(bucket)
    .file(name)
    .createResumableUpload({
      metadata: { contentType },
      // origin makes GCS echo the CORS headers on the session URI itself
      origin: "https://app.example.com",
    });
  return uri;
}

Resuming after an interruption

Every provider can resume, but the probe you send to find out how far you got is different in all three — and this is the single largest branch in a cross-provider uploader. The general recovery strategy is covered in resuming uploads after network loss; what follows is only what changes per provider.

Resume handshakes on S3, GCS and Azure Three horizontal lanes showing the probe, the comparison step and the repair step for resuming an interrupted upload on each provider. Three ways to ask “how far did I get?” S3 multipart ListParts (uploadId) diff local plan vs returned PartNumbers re-PUT the missing parts in parallel Parts sit in the bucket and are billed until you complete or abort. GCS resumable PUT with header Content-Range: bytes */* 308 Resume Incomplete Range: bytes=0-8388607 continue at the next byte on the same URI One cursor, nothing to reconcile; an abandoned session expires by itself. Azure blocks Get Block List ?blocklisttype=all compare uncommitted block IDs to your plan stage the gaps, then commitBlockList Uncommitted blocks are billed and are garbage-collected after seven days.
S3 and Azure ask for an inventory and diff it; GCS just asks for a cursor.

The GCS probe is a zero-byte PUT with Content-Range: bytes */*, and a 308 Resume Incomplete carrying Range: bytes=0-8388607 means the first 8 MiB landed. Note the off-by-one trap: Range is inclusive, so the next byte to send is 8388608, not 8388607. S3’s ListParts returns PartNumber, Size and ETag for everything already stored, so your client re-uploads only the numbers missing from that set. Azure’s Get Block List with blocklisttype=all returns both committed and uncommitted blocks, and a resumed upload almost always finds everything in the uncommitted list.

Implementation: one interface, three adapters

The pragmatic way to stay portable is to keep a single ticket shape at the API boundary and let a per-provider adapter fill it in. The browser then only branches on method and headers, never on the provider name.

// upload-ticket.ts — one ticket shape, three signing backends.
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Storage } from "@google-cloud/storage";
import {
  BlobServiceClient,
  BlobSASPermissions,
  SASProtocol,
  StorageSharedKeyCredential,
  generateBlobSASQueryParameters,
} from "@azure/storage-blob";

export type Provider = "s3" | "gcs" | "azure";

export interface UploadTicket {
  provider: Provider;
  /** Absolute URL the browser sends the bytes to. */
  url: string;
  method: "PUT";
  /** Headers the browser must replay verbatim or the request is rejected. */
  headers: Record<string, string>;
  /** Wall-clock ms after which the credential is dead. */
  expiresAt: number;
}

const TTL_SECONDS = 900;

const s3 = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1" });
const gcs = new Storage();
const azureKey = new StorageSharedKeyCredential(
  process.env.AZURE_ACCOUNT_NAME ?? "",
  process.env.AZURE_ACCOUNT_KEY ?? "",
);
const azure = new BlobServiceClient(
  `https://${process.env.AZURE_ACCOUNT_NAME}.blob.core.windows.net`,
  azureKey,
);

async function s3Ticket(bucket: string, key: string, type: string): Promise<UploadTicket> {
  const url = await getSignedUrl(
    s3,
    new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: type }),
    { expiresIn: TTL_SECONDS },
  );
  return {
    provider: "s3",
    url,
    method: "PUT",
    headers: { "content-type": type },
    expiresAt: Date.now() + TTL_SECONDS * 1000,
  };
}

async function gcsTicket(bucket: string, key: string, type: string): Promise<UploadTicket> {
  const expiresAt = Date.now() + TTL_SECONDS * 1000;
  const [url] = await gcs.bucket(bucket).file(key).getSignedUrl({
    version: "v4",
    action: "write",
    expires: expiresAt,
    contentType: type,
  });
  return { provider: "gcs", url, method: "PUT", headers: { "content-type": type }, expiresAt };
}

function azureTicket(container: string, key: string, type: string): UploadTicket {
  const expiresOn = new Date(Date.now() + TTL_SECONDS * 1000);
  const sas = generateBlobSASQueryParameters(
    {
      containerName: container,
      blobName: key,
      permissions: BlobSASPermissions.parse("cw"),
      // five minutes of slack absorbs clock skew between your host and Azure
      startsOn: new Date(Date.now() - 5 * 60 * 1000),
      expiresOn,
      protocol: SASProtocol.Https,
    },
    azureKey,
  ).toString();
  const blobUrl = azure.getContainerClient(container).getBlockBlobClient(key).url;
  return {
    provider: "azure",
    url: `${blobUrl}?${sas}`,
    method: "PUT",
    headers: { "content-type": type, "x-ms-blob-type": "BlockBlob" },
    expiresAt: expiresOn.getTime(),
  };
}

export function issueTicket(
  provider: Provider,
  container: string,
  key: string,
  contentType: string,
): Promise<UploadTicket> {
  if (provider === "s3") return s3Ticket(container, key, contentType);
  if (provider === "gcs") return gcsTicket(container, key, contentType);
  return Promise.resolve(azureTicket(container, key, contentType));
}

Reading the critical parameters line by line:

  • expiresIn: 900 / expires / expiresOn — fifteen minutes is the sweet spot for a single PUT. The clock starts at signing, not at first byte, so a user who picks a 2 GB file on a 4 Mbit link will run out of credential mid-flight; for anything over a few hundred megabytes use the chunked path instead, as covered in multipart vs single-PUT for files under 100 MB.
  • ContentType / contentType — signed on S3 and GCS, so the browser must send the identical string. Do not let fetch infer it from a Blob; set it explicitly from the ticket.
  • BlobSASPermissions.parse("cw")c create and w write, nothing else. Omitting r means a leaked SAS cannot be used to read other people’s media back out.
  • startsOn five minutes in the past — Azure rejects a SAS whose st is in the future relative to its own clock, and a host drifting by even 30 seconds produces intermittent, unreproducible AuthenticationFailed responses.
  • SASProtocol.Https — emits spr=https so the token is unusable over plain HTTP.
  • x-ms-blob-type: BlockBlob — mandatory on every Azure PUT and easy to forget, because neither S3 nor GCS needs anything like it.

Configuration reference: the limits that decide your chunk size

Constraint Amazon S3 Google Cloud Storage Azure Blob
Max object size 5 TiB 5 TiB ~190.7 TiB
Max single request 5 GiB 5 TiB (impractical above ~100 MiB) 5000 MiB per Put Blob
Chunk size rule 5 MiB – 5 GiB, last part exempt multiple of 256 KiB, last chunk exempt up to 4000 MiB per block
Max chunks per object 10,000 parts unbounded (sequential stream) 50,000 blocks
Chunk ordering any strictly ascending any
Signed credential max TTL 7 days (SigV4) 7 days (V4) 7 days for a user delegation SAS
Unfinished upload parts billed until aborted session expires in 7 days, unbilled uncommitted blocks billed, purged in 7 days
Per-chunk integrity header x-amz-checksum-crc32c x-goog-hash: crc32c= Content-MD5

The part-count caps interact with file size in a way that is easy to miss: on S3 a 10,000-part ceiling means the minimum part size for a 1 TiB file is 104 MiB, not the 5 MiB floor. Pick 8 MiB parts for a 1 TiB upload and UploadPart fails at part 10,001.

Largest object reachable at a given chunk size A log-scale chart plotting chunk size against the largest object that chunk size can build, showing S3 capped by 10,000 parts, Azure by 50,000 blocks and GCS only by the 5 TiB object limit. Largest object a given chunk size can build max uploadable object (log scale) 10 TiB 1 TiB 100 GiB 10 GiB GCS — one stream, 5 TiB object cap Azure — 50,000 blocks S3 — 10,000 parts 5 8 16 32 64 128 chunk size (MiB)
At 8 MiB chunks S3 tops out near 80 GiB and Azure near 400 GiB — GCS has no part-count wall at all.

For media the practical readings are: 8 MiB parts are fine up to about 80 GiB on S3, which covers essentially all user-generated video; above that, compute the part size from the file size rather than hardcoding it. Azure’s 50,000-block ceiling is five times more forgiving, and GCS’s sequential stream has no wall — but pays for it in throughput, since it cannot use parallel connections.

Integrity and checksums

All three will verify a checksum you supply, and all three silently accept the upload if you do not. S3 takes a trailing x-amz-checksum-crc32c (or SHA-256) per part and rejects a mismatch with BadDigest; GCS accepts x-goog-hash: crc32c=<base64> and stores crc32c on the object metadata; Azure takes Content-MD5 per block and on the committed blob. Computing the digest client-side is cheap enough to be worth it for media, and the approach is in computing file checksums in the browser with Web Crypto.

One cross-provider trap: an S3 multipart object’s ETag is not an MD5 of the file. It is the MD5 of the concatenated part MD5s with -<partCount> appended, so d41d8cd98f00b204e9800998ecf8427e-40 is a normal value and comparing it to a client-side MD5 will always fail. GCS’s crc32c and Azure’s blob-level Content-MD5 are whole-object values and do compare directly.

Cost model: what actually differs

Inbound bytes are free everywhere, so the upload itself is never the cost driver. Three things are:

  • Egress. First-tier internet egress sits within a couple of cents of $0.09/GB on all three, each with a free monthly allowance around 100 GB. Serve media through the native CDN and this mostly disappears: CloudFront origin fetches from S3 and Front Door pulls from Blob are free, while Cloud CDN bills cache fill at a reduced rate rather than zero.
  • Requests. A multipart upload costs one write request per part. A 5 GiB file at 8 MiB parts is 640 writes — at roughly $5 per million writes that is a third of a cent, invisible next to egress. It only matters if you chunk aggressively at small sizes across millions of uploads.
  • Abandoned uploads. S3 bills orphaned parts at full storage rates forever unless you set a lifecycle rule; see expiring incomplete multipart uploads automatically. Azure bills uncommitted blocks but purges them after seven days. GCS charges nothing for an unfinalised resumable session. On a service with a 20% upload abandonment rate this is the one line item where the providers genuinely differ.

Configuration gotchas

S3: SignatureDoesNotMatch on a URL that looks correct

The full response body is <Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided.</Message></Error>. In browser uploads the cause is almost always a Content-Type mismatch — the ticket was signed for image/jpeg, the browser sent image/jpeg;charset=UTF-8, or the file’s type was the empty string. Send the header from the ticket verbatim, or omit ContentType from PutObjectCommand entirely and set it later with a copy.

GCS: Invalid request. The number of bytes uploaded is required to be equal or greater than 262144

Returned as 400 on any non-final resumable chunk that is not a multiple of 256 KiB. A common trigger is deriving chunk size from a percentage of file size. Round down: Math.floor(target / 262144) * 262144, with a 256 KiB minimum.

Azure: MissingRequiredHeader naming x-ms-blob-type

The body reads An HTTP header that's mandatory for this request is not specified. with <HeaderName>x-ms-blob-type</HeaderName>. Every Azure blob PUT needs x-ms-blob-type: BlockBlob, and the header must also appear in the storage account’s CORS AllowedHeaders or the preflight fails before the PUT is ever attempted.

Azure: InvalidBlockId when block IDs are not the same length

The specified block ID is invalid. The block ID must be Base64 encoded. fires when block 9 was encoded from "9" and block 10 from "10", because Azure requires every block ID for a blob to be the same byte length before encoding. Zero-pad first: btoa(String(index).padStart(6, "0")).

Verification

Mint one ticket per provider and push a real file through it. The status codes differ, which is itself a useful signal.

# S3 and GCS: expect HTTP 200 and an ETag response header.
curl -sS -o /dev/null -D - -X PUT "$S3_URL" \
  -H "content-type: image/jpeg" --data-binary @sample.jpg

curl -sS -o /dev/null -D - -X PUT "$GCS_URL" \
  -H "content-type: image/jpeg" --data-binary @sample.jpg

# Azure: expect HTTP 201 Created, and note the extra mandatory header.
curl -sS -o /dev/null -D - -X PUT "$AZURE_URL" \
  -H "content-type: image/jpeg" -H "x-ms-blob-type: BlockBlob" \
  --data-binary @sample.jpg

# GCS resume probe: expect 308 and a range header ending at the last stored byte.
curl -sS -o /dev/null -D - -X PUT "$GCS_SESSION_URI" \
  -H "content-range: bytes */*" --data-binary ""

A passing run prints HTTP/2 200 twice, then HTTP/2 201, then HTTP/2 308 with range: bytes=0-8388607. If the Azure call returns 403 while S3 and GCS pass, check clock skew on the signing host before you touch anything else.

The recommendation

Already on AWS, or want the widest ecosystem of uploader tooling: choose S3 — multipart is battle-tested and nearly every client library targets it first, and it is the only one of the three with a mature POST-policy path for enforcing size at the edge. Already on Google Cloud, or you want the least code for large resumable uploads: choose GCS, accepting that sequential chunks cost you throughput on fast connections. Already on Azure, or you need client-controlled out-of-order assembly and a very high block ceiling: choose Azure Blob, and budget an afternoon for the SAS and CORS quirks.

Let your existing cloud decide unless egress economics or a specific chunking need pulls you elsewhere. Behind a ticket interface like the one above, switching later is a contained refactor of one adapter plus the chunking branch — not a rewrite. If you have not yet decided whether to go direct at all, the numbers in direct S3 uploads vs proxy uploads are the better place to start.

Frequently Asked Questions

Which provider is cheapest for a media upload service?

Storage-at-rest and request pricing are close enough to be noise; egress decides it, and all three are dramatically cheaper through their own CDN than from the bucket. The one structural difference is abandoned uploads: S3 bills orphaned parts indefinitely, Azure for seven days, GCS not at all.

Can one frontend uploader serve all three?

For a single signed PUT, yes — it is a fetch with a URL and headers from the ticket, plus x-ms-blob-type on Azure. For chunked uploads the three models are genuinely different, so the chunk scheduler needs a per-provider branch: parallel parts with ETag collection, sequential ranges against one URI, or parallel blocks plus a commit call.

Does GCS support S3-style multipart?

Yes, through its XML API, and it is the right choice if you are porting an S3 uploader unchanged. The native resumable session is simpler and has no part-count cap, so use the XML multipart path for compatibility rather than as a default.

Does Azure’s account-wide CORS matter in practice?

It does when several apps with different origin requirements share one storage account, because there is one CORS ruleset for the whole Blob service and only five rules allowed. Separate storage accounts give you the per-bucket isolation S3 and GCS have by default.

How large can a signed upload credential’s lifetime be?

Seven days is the ceiling for an S3 SigV4 presigned URL, a GCS V4 signed URL, and an Azure user delegation SAS. In practice sign for 15 minutes for a single PUT and re-mint per chunk for long multipart uploads, so a paused upload asks your API for fresh credentials rather than holding a week-long write grant.