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

  1. A bucket and a service account for your API with roles/storage.objectCreator on it.
  2. Bucket CORS allowing PUT from your origin and exposing the Range header — see configuring CORS for GCS and Azure Blob uploads.
  3. @google-cloud/storage 7.x on the server.
  4. Somewhere to keep the session URI between page loads (IndexedDB, as in persisting upload state in IndexedDB).

The protocol

Resumable upload exchange with Cloud Storage The browser asks the API for a session. The API calls Cloud Storage to start a resumable upload with the browser's origin and receives a session URI, which it returns. The browser sends chunks with Content-Range headers; each non-final chunk gets a 308 response with the Range received so far. The final chunk gets 200 with object metadata. After a failure, an empty PUT with bytes star slash total returns the current Range. One session URI, many chunk PUTs browser your API Cloud Storage start (name, size, type) POST uploadType=resumable session URI PUT chunk · Content-Range: bytes 0-8388607/52428800 308 · Range: bytes=0-8388607 … last chunk · bytes 50331648-52428799/52428800 200 · object metadata Status query after a failure: empty PUT with Content-Range: bytes */52428800 → 308 with the received Range.
Only the first call needs your credentials; the session URI carries the permission from then on.

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

  • origin when creating the session. Cloud Storage applies the bucket’s CORS rules to session requests only if the session was created with an Origin. 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 Range header. Cloud Storage may persist less than a whole chunk. Continuing from the server’s reported end, not from end, prevents gaps and duplicate bytes.
  • Query after errors. A failed request may still have delivered some or all of its bytes. Asking with bytes */total before 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

Resuming a GCS upload after a page reload Before uploading, the browser stores the session URI with the file's name, size and last modified time in IndexedDB. After a reload, the user reselects or the stored file handle is reopened, the file identity is compared, and a status query returns the offset. Uploading continues from that offset. If the session has expired, a new session is created. The session URI is the resume token store URI + name, size, mtime reload same file again? bytes */total 308 → offset continue from offset 404/410 → new session
Match the file before resuming: the session is for specific bytes.

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).

Responses the upload loop must handle A 308 response means continue from the Range header. A 200 or 201 means the upload is complete. A 400 usually means a misaligned chunk. A 404 or 410 means the session expired or was cancelled. A 5xx or network error means query the offset and retry with backoff. Five response classes, five reactions response reaction 308 Resume Incomplete continue from Range end + 1 200 / 201 done — confirm with your API 400 bug: misaligned chunk or bad Content-Range 404 / 410 session gone — new session, restart 5xx / network error backoff, query offset, retry
Only 404/410 loses progress; everything else resumes from the server's offset.

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.