Resumable Uploads to GCS with Session URIs
Have your server start a resumable upload with the Cloud Storage JSON API (or file.createResumableUpload() in the Node client), passing the browser’s Origin so the session allows CORS, and return the session URI to the browser. The browser then PUTs the file in chunks to that URI, each with Content-Range: bytes start-end/total and a length that is a multiple of 256 KiB except for the last; Cloud Storage answers 308 Resume Incomplete with a Range header until the final chunk, which returns 200 or 201 with the object metadata. To resume after a failure or reload, PUT an empty body with Content-Range: bytes */total to learn how many bytes arrived, and continue from there. Session URIs are valid for a week and act as bearer credentials, so store them only where the user’s session data lives.
A session URI is Google’s built-in resumable protocol: no multipart bookkeeping, no part ETags, and the server tracks the offset for you. Because the session is created server-side with your credentials, the browser never holds a key and cannot choose the object name, content type or size limits you did not approve. This page belongs to resumable upload state machines in frontend UX, chunking and progress tracking; the server-side client library is covered in uploading to GCS with Node.js client libraries.
When to use this approach
- Files larger than about 10 MB go to Google Cloud Storage from the browser.
- Uploads must survive network drops, sleeping laptops and page reloads.
- You prefer a single-stream protocol over managing parallel parts.
Prerequisites
- A bucket and a service account for your API with
roles/storage.objectCreatoron it. - Bucket CORS allowing
PUTfrom your origin and exposing theRangeheader — see configuring CORS for GCS and Azure Blob uploads. @google-cloud/storage7.x on the server.- Somewhere to keep the session URI between page loads (IndexedDB, as in persisting upload state in IndexedDB).
The protocol
Implementation
Server — create the session with constraints you control:
import { Storage } from "@google-cloud/storage";
import { randomUUID } from "node:crypto";
const storage = new Storage();
const bucket = storage.bucket(process.env.BUCKET!);
const ALLOWED = new Set(["video/mp4", "video/quicktime", "image/jpeg", "image/png"]);
const MAX = 5 * 1024 ** 3;
app.post("/api/uploads/session", async (req, res) => {
const { name, size, type } = req.body;
if (!ALLOWED.has(type) || !(size > 0 && size <= MAX)) return res.status(400).json({ error: "not allowed" });
const objectName = `uploads/${req.user.id}/${randomUUID()}`;
const [uri] = await bucket.file(objectName).createResumableUpload({
origin: req.get("origin"), // enables CORS on the session for this origin
metadata: { contentType: type, metadata: { originalName: name, owner: req.user.id } },
preconditionOpts: { ifGenerationMatch: 0 }, // never overwrite an existing object
});
res.json({ uri, objectName });
});
Browser — upload in aligned chunks, resuming from the server’s offset:
const ALIGN = 256 * 1024;
const CHUNK = 32 * ALIGN; // 8 MiB
async function queryOffset(uri: string, total: number, signal: AbortSignal): Promise<number | "done"> {
const res = await fetch(uri, { method: "PUT", headers: { "Content-Range": `bytes */${total}` }, signal });
if (res.status === 200 || res.status === 201) return "done";
if (res.status === 308) {
const range = res.headers.get("Range"); // "bytes=0-8388607", absent if nothing received
return range ? Number(range.split("-")[1]) + 1 : 0;
}
if (res.status === 404 || res.status === 410) throw new Error("session-expired");
throw new Error(`status query failed: ${res.status}`);
}
export async function uploadResumable(file: File, uri: string, onProgress: (sent: number) => void, signal: AbortSignal) {
let offset = 0;
const first = await queryOffset(uri, file.size, signal);
if (first === "done") return;
offset = first;
let failures = 0;
while (offset < file.size) {
const end = Math.min(offset + CHUNK, file.size);
try {
const res = await fetch(uri, {
method: "PUT",
headers: { "Content-Range": `bytes ${offset}-${end - 1}/${file.size}` },
body: file.slice(offset, end),
signal,
});
if (res.status === 200 || res.status === 201) { onProgress(file.size); return; }
if (res.status !== 308) throw new Error(`chunk failed: ${res.status}`);
const range = res.headers.get("Range");
offset = range ? Number(range.split("-")[1]) + 1 : 0; // trust the server, not our arithmetic
failures = 0;
onProgress(offset);
} catch (e) {
if (signal.aborted) throw e;
if (++failures > 6) throw e;
await new Promise((r) => setTimeout(r, Math.min(32_000, 1000 * 2 ** failures) * (0.5 + Math.random())));
const q = await queryOffset(uri, file.size, signal);
if (q === "done") return;
offset = q;
}
}
}
Line-by-line on the decisions that matter
originwhen creating the session. Cloud Storage applies the bucket’s CORS rules to session requests only if the session was created with anOrigin. Without it, chunk PUTs fail with CORS errors even though the bucket configuration looks right.- Session created by the server. The server decides the object name, content type and metadata, and can refuse by size and type before any bytes move. The browser receives a URI that can upload exactly one object.
ifGenerationMatch: 0. The upload fails rather than overwriting if an object with that name somehow exists. With random names this is belt and braces, but it costs nothing.- 256 KiB alignment. Non-final chunks must be multiples of 262,144 bytes or Cloud Storage returns 400. Using a multiple of the alignment as the chunk size satisfies this; adapting chunk size to measured throughput keeps alignment while varying size.
- Offset from the
Rangeheader. Cloud Storage may persist less than a whole chunk. Continuing from the server’s reported end, not fromend, prevents gaps and duplicate bytes. - Query after errors. A failed request may still have delivered some or all of its bytes. Asking with
bytes */totalbefore retrying makes every retry exact. - Expired sessions. After a week, or if cancelled, the URI returns 404 or 410. The only fix is a new session and a restart from zero, so surface that as a distinct state.
Reloads and resumption
Store the session URI, object name and the file’s identity (name, size, lastModified) in IndexedDB as soon as the session is created. After a reload, when the user chooses the same file again — or the stored File or file handle is still readable — compare identity, query the session and continue. If the identity differs, do not resume: the session would receive bytes from a different file, and the resulting object would be corrupt. Start a new session instead.
Treat the stored URI like a password. Anyone who has it can upload to that object until it expires, so keep it only in origin-scoped storage, clear it when the upload completes or is cancelled, and never log it. For cancellation, send DELETE to the session URI; Cloud Storage responds 499 and the URI becomes unusable.
Integrity and completion
Cloud Storage computes MD5 (for non-composite objects) and CRC32C for every upload. To verify end to end, compute a CRC32C in the browser as you read the chunks and compare it with the crc32c field in the final 200 response’s JSON, or send X-Goog-Hash: crc32c=<base64> with the last chunk so Cloud Storage rejects a mismatch itself. Then confirm the upload with your API, which reads the object’s metadata and records it — the same confirm step as in confirming uploads before committing database records — or rely on a Pub/Sub notification (processing GCS uploads with Pub/Sub notifications).
Configuration gotchas
CORS errors on chunk PUTs only. The session was created without origin, or the bucket CORS does not expose Range. Browsers hide the Range header from JavaScript unless it is listed in responseHeader in the bucket’s CORS configuration.
The browser treats 308 as a redirect. Older fetch polyfills and some proxies follow 308 as a permanent redirect. Native fetch and XHR in current browsers return it to your code for PUT requests to Cloud Storage because no Location header is sent; avoid polyfills on this path.
Progress jumps backwards. Cloud Storage persisted less than you sent. Always set progress from the Range header, never from what you think you sent.
Chunks rejected with 400 after changing chunk size. A non-final chunk was not a multiple of 256 KiB. Align every calculated size down to the nearest multiple.
Verification
# Create a session and upload a file in two chunks with curl.
URI=$(curl -s -X POST localhost:3000/api/uploads/session -H 'content-type: application/json' \
-H 'origin: http://localhost:5173' -d '{"name":"a.mp4","size":10485760,"type":"video/mp4"}' | jq -r .uri)
head -c 8388608 a.mp4 | curl -s -o /dev/null -w '%{http_code} ' -X PUT "$URI" -H 'Content-Range: bytes 0-8388607/10485760' --data-binary @-
curl -s -o /dev/null -D - -X PUT "$URI" -H 'Content-Range: bytes */10485760' | grep -i '^range' # bytes=0-8388607
tail -c 2097152 a.mp4 | curl -s -X PUT "$URI" -H 'Content-Range: bytes 8388608-10485759/10485760' --data-binary @- | jq .size
Frequently Asked Questions
Can I upload chunks in parallel to one session?
No. A resumable session accepts bytes in order. For parallel transfers, use the XML API’s multipart upload (S3-compatible) or parallel composite uploads with separate objects and compose them afterwards.
Should I use signed URLs instead of session URIs?
A V4 signed URL with x-goog-resumable: start lets the browser create the session itself. Server-created sessions are simpler and keep all decisions server-side; signed URLs avoid one server round trip.
How long can a session stay open?
Up to a week from creation. For very large uploads that might span longer, create sessions lazily and store progress so a restart is at least a known quantity.