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
- Node 20+ and one or more of
@aws-sdk/client-s33.x with@aws-sdk/s3-request-presigner,@google-cloud/storage7.x,@azure/storage-blob12.x. - Credentials on the signing host: an IAM role with
s3:PutObjectonarn:aws:s3:::your-bucket/uploads/*, a GCP service account withroles/storage.objectCreatorplusiam.serviceAccounts.signBlob, and an Azure storage account key or a user delegation key. - CORS already allowing
PUTfrom 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.
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.
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.
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 letfetchinfer it from aBlob; set it explicitly from the ticket.BlobSASPermissions.parse("cw")—ccreate andwwrite, nothing else. Omittingrmeans a leaked SAS cannot be used to read other people’s media back out.startsOnfive minutes in the past — Azure rejects a SAS whosestis in the future relative to its own clock, and a host drifting by even 30 seconds produces intermittent, unreproducibleAuthenticationFailedresponses.SASProtocol.Https— emitsspr=httpsso 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.
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.