Chunked browser uploads to Google Storage with JavaScript
Send large files directly from your browser to a private Google Cloud Storage (GCS) bucket, without relaying the file through Node.js. The server authenticates one owner and opens a resumable upload session; the browser sends aligned chunks and asks GCS where to continue after an interruption. The example includes pause/resume in the same open page and an independent byte check after completion.
Why chunked uploads?
A failed connection can leave more bytes stored than the browser has acknowledged. GCS’s resumable protocol lets the browser query that accepted position before sending more data. These requests build one object, so there are no separate chunk objects to compose or delete.
Configure Google Cloud Storage
Use Bash on Linux or macOS, a current Node.js 24 LTS release, Corepack, and an existing private bucket. The package setup below pins Yarn 4.18.1 and Google Auth Library 11.3.0. Node.js 24.22.0 is the tested LTS version; keep using maintained security updates within that major.
The server needs storage.objects.create on the bucket. The bucket-scoped
roles/storage.objectCreator role supplies it for this create-only example. The later byte check
also needs storage.objects.get, available through roles/storage.objectViewer. See Google’s
required upload permissions.
Use existing Application Default Credentials (ADC)
with those permissions. If ADC is not configured, install the Google Cloud CLI and run
gcloud auth application-default login before starting the server. A successful login alone does
not establish access to your bucket.
Paste this in the directory where you want the new gcs-upload project. It refuses an existing
directory, leaves your shell in place, and gives the project its own Yarn configuration and lockfile:
(
set -eu
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
mkdir gcs-upload
cd gcs-upload
printf '%s\n' '{"name":"gcs-upload","private":true,"type":"module","packageManager":"yarn@4.18.1","dependencies":{"google-auth-library":"11.3.0"}}' > package.json
printf '%s\n' 'nodeLinker: node-modules' 'ignorePath: true' 'enableGlobalCache: false' > .yarnrc.yml
: > yarn.lock
corepack yarn@4.18.1 install
)
If installation fails, keep the created files and retry (cd gcs-upload && corepack yarn@4.18.1 install).
After it succeeds, open a terminal in gcs-upload and save the following four files there.
The JSON API handles CORS independently of bucket CORS rules. The server supplies the exact page origin during session creation. The browser then adds that origin to its chunk requests. CORS permits the browser to read responses; the upload token controls who can create a session.
Back-end: create authenticated upload sessions
Save this as server.mjs. The configured token identifies the one owner of this local tool. Every
session requires that token, and the server chooses an unpredictable object name inside
uploads/owner/. Clients cannot supply bucket names, destination paths, or object names to delete.
The ifGenerationMatch=0 precondition also prevents overwriting an existing object.
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { GoogleAuth } from 'google-auth-library'
const ORIGIN = 'http://127.0.0.1:8080'
const MAX_SIZE = 10 * 1024 ** 3
const assets = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/upload.mjs', ['upload.mjs', 'text/javascript; charset=utf-8']],
['/app.mjs', ['app.mjs', 'text/javascript; charset=utf-8']],
])
export function createUploadServer({ auth, bucket, token }) {
if (!bucket || !token || token.length < 32) throw new Error('Set BUCKET and a 32-character token')
const digest = (value) => createHash('sha256').update(value).digest()
const expected = digest(`Bearer ${token}`)
return createServer((req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Referrer-Policy', 'no-referrer')
const reply = (status, body) => {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function handle() {
const asset = assets.get(req.url)
if (req.method === 'GET' && asset) {
const body = await readFile(new URL(asset[0], import.meta.url))
res.writeHead(200, { 'Content-Type': asset[1] })
res.end(body)
return
}
if (req.method !== 'POST' || req.url !== '/sessions') return reply(404, { error: 'Not found' })
if (!timingSafeEqual(expected, digest(req.headers.authorization ?? ''))) {
return reply(401, { error: 'Invalid upload token' })
}
if (req.headers.origin !== ORIGIN) return reply(403, { error: 'Invalid origin' })
const chunks = []
let bytes = 0
for await (const chunk of req) {
bytes += chunk.length
if (bytes > 1024) return reply(413, { error: 'Request too large' })
chunks.push(chunk)
}
let input
try {
input = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))
} catch {
return reply(400, { error: 'Invalid JSON' })
}
const size = input?.size
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_SIZE) {
return reply(400, { error: 'Choose a nonempty file of at most 10 GiB' })
}
const name = `uploads/owner/${randomUUID()}`
const url = new URL(`https://storage.googleapis.com/upload/storage/v1/b/${encodeURIComponent(bucket)}/o`)
url.search = new URLSearchParams({ uploadType: 'resumable', name, ifGenerationMatch: '0' })
const client = await auth.getClient()
const response = await client.request({
url: url.href,
method: 'POST',
headers: { Origin: ORIGIN, 'X-Upload-Content-Length': String(size) },
data: { contentType: 'application/octet-stream' },
timeout: 30_000,
retry: false,
})
const sessionUrl = response.headers.get('location')
if (!sessionUrl || new URL(sessionUrl).origin !== 'https://storage.googleapis.com') {
throw new Error('Invalid session response')
}
reply(201, { sessionUrl, name })
}
handle().catch(() => reply(502, { error: 'Could not create upload session' }))
})
}
if (import.meta.main) {
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_write'] })
const server = createUploadServer({ auth, bucket: process.env.BUCKET, token: process.env.UPLOAD_TOKEN })
server.on('error', () => {
console.error('Could not start upload server. Check whether port 8080 is already in use.')
process.exitCode = 1
})
server.listen(8080, '127.0.0.1', () => console.log(`Open ${ORIGIN}`))
}
The Google Auth Library keeps Google credentials on the server. The returned session URL is itself a bearer credential: anyone who has it can upload to that one destination. It is not a 15-minute signed URL; GCS resumable sessions expire after one week. Keep session URLs out of logs and shared storage.
Front-end: a minimal chunked uploader
Save this as upload.mjs. The 8 MiB chunk size is a multiple of GCS’s required 256 KiB alignment;
the last chunk can be smaller. A 308 response means the upload is incomplete, so the code handles
it before the ordinary HTTP error check. It reads the acknowledged byte range instead of assuming
GCS accepted the entire request. See the resumable upload protocol.
const CHUNK_SIZE = 8 * 1024 * 1024
export async function uploadFileWithChunks(file, sessionUrl, { signal, onProgress = () => {} } = {}) {
if (file.size < 1 || file.size > 10 * 1024 ** 3) throw new Error('Invalid file size')
let offset = 0
let probe = true
let failures = 0
while (true) {
signal?.throwIfAborted()
const end = Math.min(offset + CHUNK_SIZE, file.size)
let response
try {
response = await fetch(sessionUrl, {
method: 'PUT',
credentials: 'omit',
signal,
headers: {
'Content-Range': probe ? `bytes */${file.size}` : `bytes ${offset}-${end - 1}/${file.size}`,
},
body: probe ? new Blob([]) : file.slice(offset, end),
})
} catch (error) {
signal?.throwIfAborted()
if (++failures > 5) throw error
await backoff(failures, signal)
probe = true
continue
}
await response.body?.cancel()
if (response.status === 200 || response.status === 201) {
onProgress(100)
return
}
if (response.status === 429 || response.status >= 500) {
if (++failures > 5) throw new Error('Upload retries exhausted')
await backoff(failures, signal)
probe = true
continue
}
if (response.status !== 308) throw new Error(`Upload failed (HTTP ${response.status})`)
const range = response.headers.get('range')
const match = range === null ? null : /^bytes=0-(\d+)$/.exec(range)
if (range !== null && !match) throw new Error('Invalid acknowledged range')
const next = match ? Number(match[1]) + 1 : 0
if (!Number.isSafeInteger(next) || next < offset || next >= file.size || (!probe && next > end)) {
throw new Error('Invalid acknowledged position')
}
if (!probe && next === offset) {
if (++failures > 5) throw new Error('Upload made no progress')
await backoff(failures, signal)
probe = true
continue
}
if (next > offset) failures = 0
offset = next
onProgress((offset / file.size) * 100)
probe = false
}
}
function backoff(attempt, signal) {
signal?.throwIfAborted()
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', abort)
resolve()
}, 500 * 2 ** (attempt - 1))
signal?.addEventListener('abort', abort, { once: true })
})
}
Retry with exponential back-off
Network errors, rate limits, and server errors trigger up to five retries between progress updates.
Each retry first queries the stored position with an empty PUT. A pause aborts both the active
request and any retry delay. Permission failures, expired sessions, and malformed ranges stop the
upload instead of looping forever. If a final response was lost, the next status probe can confirm
completion without uploading the file again.
Save the page as index.html:
<!doctype html>
<html lang="en">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Resumable GCS upload</title>
<label>Upload token <input id="token" type="password" autocomplete="off" /></label>
<label>File <input id="file" type="file" /></label>
<button id="upload">Upload / resume</button>
<button id="pause" disabled>Pause</button>
<progress id="progress" max="100" value="0" aria-label="Upload progress"></progress>
<p id="status" role="status">Choose a file.</p>
<script type="module" src="/app.mjs"></script>
</html>
Save the controls as app.mjs:
import { uploadFileWithChunks } from './upload.mjs'
const fileInput = document.getElementById('file')
const tokenInput = document.getElementById('token')
const upload = document.getElementById('upload')
const pause = document.getElementById('pause')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
let session
let controller
fileInput.addEventListener('change', () => {
session = undefined
progress.value = 0
})
pause.addEventListener('click', () => controller?.abort())
upload.addEventListener('click', async () => {
const file = fileInput.files[0]
if (!file) return
controller = new AbortController()
upload.disabled = fileInput.disabled = tokenInput.disabled = true
pause.disabled = false
status.textContent = 'Uploading…'
try {
if (!session) {
const response = await fetch('/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokenInput.value}` },
body: JSON.stringify({ size: file.size }),
signal: controller.signal,
})
if (!response.ok) throw new Error(`Session failed (HTTP ${response.status})`)
session = await response.json()
}
await uploadFileWithChunks(file, session.sessionUrl, {
signal: controller.signal,
onProgress: (value) => { progress.value = value },
})
status.textContent = `Uploaded to ${session.name}`
session = undefined
} catch {
status.textContent = controller.signal.aborted
? 'Paused. Click Upload / resume to continue.'
: 'Upload failed. Retry, or reselect the file to start a new session.'
} finally {
upload.disabled = fileInput.disabled = tokenInput.disabled = false
pause.disabled = true
}
})
Replace the bucket placeholder, then start the server from gcs-upload. At the hidden prompt,
paste a random secret of at least 32 characters from your password manager. Open
http://127.0.0.1:8080, enter that secret in Upload token,
choose a nonempty file of at most 10 GiB, and click Upload / resume.
Use Pause and the same upload button to continue. Keep the file
unchanged while uploading. Stop the server with Ctrl+C when you finish:
(
export BUCKET='your-existing-private-bucket'
read -r -s -p 'Upload token: ' UPLOAD_TOKEN &&
printf '\n' &&
export UPLOAD_TOKEN &&
node server.mjs
)
Verify the stored bytes
The page reports Uploaded to followed by the object name after
GCS confirms completion. Before using the object downstream, compare its bytes with the selected
local file. Save this as verify.mjs in the same project. It checks metadata, pins the download to
that exact generation, and streams both files through SHA-256 without holding the whole file in memory.
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { GoogleAuth } from 'google-auth-library'
const [file, name, secondsText = '7200', ...extra] = process.argv.slice(2)
const seconds = Number(secondsText)
if (!file || !name || !process.env.BUCKET || extra.length ||
!Number.isSafeInteger(seconds) || seconds < 1 || seconds > 86400) {
console.error('Usage: BUCKET=your-bucket node verify.mjs FILE OBJECT_NAME [SECONDS: 1–86400]')
process.exit(1)
}
let phase = 'local-file'
// A process deadline also covers ADC refresh, which has its own HTTP transport.
const deadline = setTimeout(() => {
console.error(`Verification deadline exceeded during ${phase}. Retry with a larger SECONDS value.`)
process.exit(124)
}, seconds * 1000)
try {
const info = await stat(file)
if (!info.isFile() || info.size < 1 || info.size > 10 * 1024 ** 3) {
throw new Error('Choose a nonempty regular file of at most 10 GiB')
}
async function hash(stream) {
const digest = createHash('sha256')
let bytes = 0
for await (const chunk of stream) {
bytes += chunk.length
digest.update(chunk)
}
return { bytes, sha256: digest.digest('hex') }
}
const local = await hash(createReadStream(file))
phase = 'credentials'
const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_only'] })
const client = await auth.getClient()
await client.getAccessToken()
phase = 'metadata'
const url = new URL(`https://storage.googleapis.com/storage/v1/b/${encodeURIComponent(process.env.BUCKET)}/o/${encodeURIComponent(name)}`)
const metadata = await client.request({ url: url.href, retry: false, timeout: 0 })
const object = metadata.data
if (object.name !== name || !/^[1-9]\d*$/.test(object.generation) ||
Number(object.size) !== local.bytes) throw new Error('Object metadata does not match the local file')
url.search = new URLSearchParams({ alt: 'media', generation: object.generation })
phase = 'download'
const response = await client.request({ url: url.href, responseType: 'stream', retry: false, timeout: 0 })
const stored = await hash(response.data)
phase = 'integrity'
if (stored.bytes !== local.bytes || stored.sha256 !== local.sha256) {
throw new Error('Stored bytes do not match the local file')
}
console.log(`Verified ${stored.bytes} bytes (generation ${object.generation}).`)
} catch (error) {
const status = Number.isInteger(error?.response?.status) ? error.response.status : null
const data = error?.response?.data
const oauthError = ['invalid_grant', 'invalid_client', 'unauthorized_client'].includes(data?.error)
? data.error : null
const oauthSubtype = data?.error_subtype === 'invalid_rapt' ? 'invalid_rapt' : null
console.error(JSON.stringify({ error: 'verification-failed', phase, status, oauthError, oauthSubtype }))
console.error(`Verification failed during ${phase}. Check ADC and bucket access for credentials/metadata errors; check the selected file and object for an integrity error.`)
process.exitCode = 1
} finally {
clearTimeout(deadline)
}
Run it in a second terminal from gcs-upload, replacing the file path and copying the complete
object name from the page:
BUCKET='your-existing-private-bucket' node verify.mjs './large-file.bin' 'uploads/owner/your-object-id' 7200
For files up to 10 GiB, the default is 7,200 seconds (two hours) for the entire verification:
local hashing, credential acquisition and OAuth refresh, metadata, and the full download. Set
SECONDS from 1 to 86,400 when your connection needs a different budget; a 10 GiB download alone
needs about 12 Mbit/s to fit in two hours. The SDK has no additional whole-body timeout here.
At the deadline the process exits with status 124 and prints no verified result. Rerun with a larger
budget after inspecting access failures separately; extending a deadline cannot fix bad credentials
or a byte mismatch. The verifier reads the stored object and does not re-upload or change it.
Persist progress across reloads
The file and session stay in this page’s memory. Pausing keeps both; reloading or selecting another file starts a new session. An incomplete session expires without publishing a completed object. This example does not recover sessions after a reload.
A multi-user application would need authenticated server-side session ownership and a way to verify that a reselected file has the same content. A name, size, or saved progress percentage is not enough.
Security and performance best practices
The token identifies one trusted owner. Keep the server bound to loopback; this example does not supply a multi-user login system or quotas. Session URLs are upload credentials, so keep them out of logs, browser storage, and shared screenshots. Google credentials stay in Node.js.
The browser sends one 8 MiB chunk at a time and reports progress only after an acknowledgment. Pausing aborts the current request and retry delay; some bytes may already be stored when you pause. The next attempt asks GCS for its accepted offset. Empty files and files above 10 GiB are refused. A terminal session error requires reselecting the file to start again. The create-only precondition prevents replacement of an existing object even if a destination name collides.
For a richer file selection interface, see Uppy. Its integration still needs an upload transport appropriate to your application.
