Multi-megabyte image uploads should not travel through a Pages Function on the way to storage. They do not have to.
This is part of the photography portfolio series . It covers how the admin upload flow on alex.edestudio.us and jamie.edestudio.us sends photos straight from the browser to Cloudflare Images without proxying the bytes through my own code.
The three-request handshake ¶
Every upload is three sequential requests from the browser. The Pages Function in the middle never sees the image bytes.
Browser Pages Function Cloudflare Images
─────── ────────────── ─────────────────
│ │ │
│ POST /api/admin/upload-url ──────────────────► │
│ │ (mints upload URL) │
│ ◄─── { id, uploadURL } │ │
│ │ │
│ POST <uploadURL> (multipart/form-data) ──────► │
│ ◄────────────── 200 OK ──────────────────────── │
│ │ │
│ POST /api/admin/photos │ │
│ { cf_image_id: id, title, tags, ... } ────► │
│ │ (writes D1 rows) │
│ ◄────── 200 OK ─────────│ │
│ │ │
Step 1 is a tiny Pages Function call: in, out, no body of consequence. Step 2 is the big upload, browser to Cloudflare Images , direct. Step 3 is another tiny call that writes D1 metadata once Cloudflare has the photo.
Minting the upload URL ¶
The Pages Function calls Cloudflare’s Direct Creator Upload endpoint and forwards the result to the browser:
async function cfDirectUpload(env: Env): Promise<{ id: string; uploadURL: string }> {
const account = env.CF_ACCOUNT_ID;
const token = env.CF_API_TOKEN;
if (!account || !token) {
throw new Error("Images API not configured");
}
const form = new FormData();
form.append("requireSignedURLs", "false");
const res = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${account}/images/v2/direct_upload`,
{ method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form },
);
const body = (await res.json()) as {
success: boolean;
errors?: { message: string }[];
result?: { id: string; uploadURL: string };
};
if (!res.ok || !body.success || !body.result) {
throw new Error(body.errors?.map((e) => e.message).join("; ") || "direct_upload failed");
}
return body.result;
}
uploadURL is short-lived: it is single-use, signed, and tied to one image id. By the time someone could exfiltrate one from a logged-out browser, it would be useless.
The requireSignedURLs: false setting matters: it controls whether the delivered image needs a signed URL to view, not whether the upload itself is auth’d. The upload URL is always single-use regardless.
Posting the file straight to Cloudflare ¶
On the browser side, three lines do the work:
const { uploadURL, id } = await requestUploadUrl();
const cleaned = await stripFileMetadata(it.file);
await uploadWithProgress(uploadURL, cleaned, (pct) => updateItem(it.key, { progress: pct }));
stripFileMetadata removes EXIF before the file leaves the browser; that is covered in the EXIF strip post
in this series.
uploadWithProgress is a thin XMLHttpRequest wrapper. The reason it’s not fetch is that the Fetch API still does not have a clean way to report upload progress. XMLHttpRequest exposes xhr.upload.onprogress directly:
function uploadWithProgress(uploadUrl: string, file: File, onProgress: (pct: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(xhr.statusText)));
xhr.onerror = () => reject(new Error("Upload failed"));
const form = new FormData();
form.append("file", file);
xhr.send(form);
});
}
FormData with field name file is what Cloudflare’s direct-upload endpoint expects. Get the field name wrong and it returns 400 with a polite “must contain a file field” message.
Why not proxy through the Pages Function ¶
Three reasons.
Request body limits. Pages Functions are Workers under the hood, and Workers have a memory ceiling. Streaming a 30 MB RAW file through one is possible but adds latency and exhausts CPU time on the free tier surprisingly quickly. Direct uploads avoid all of that: my code never holds the bytes.
Latency. Even a perfect proxy doubles the trip: browser → Function → Images. Direct uploads do browser → Images, full stop.
Scope. A short-lived single-use upload URL has a tiny attack surface compared to a Pages Function that accepts arbitrary image bodies. Even if someone got hold of a URL, all they could do is upload one image to one image id. They could not list, modify, or delete anything else.
The race I almost shipped ¶
The order in the client matters more than it looks:
const { uploadURL, id } = await requestUploadUrl();
const cleaned = await stripFileMetadata(it.file);
await uploadWithProgress(uploadURL, cleaned, ...); // <-- wait for upload
await createPhoto({ cf_image_id: id, /* ... */ }); // <-- only then write D1
In an earlier version I fired createPhoto in parallel with uploadWithProgress to “save a round trip”. It worked perfectly in dev. In production, sometimes the photo detail page would render with imagedelivery.net/<hash>/<id>/grid returning a 404 for a few seconds, until Cloudflare Images finished processing the upload. The D1 row existed, the image did not.
The fix was the await on line three. Wait for the upload to resolve, then write D1. The Cloudflare Images upload response is the signal that the image id is real; anything that depends on it must come after.
Worth knowing if you are tempted to parallelise.
Who is allowed to call any of this ¶
The admin endpoints (/api/admin/upload-url, /api/admin/photos) sit behind Cloudflare Access
in production. Access verifies the visitor against an email allowlist before the request ever reaches a Pages Function
. The Function then reads the verified email out of a header:
export function getAccessUser(request: Request, env: Env): string | null {
const access = request.headers.get("Cf-Access-Authenticated-User-Email");
if (access) return access;
if (env.ENVIRONMENT !== "production") {
const dev = request.headers.get("X-Dev-User");
if (dev) return dev;
}
return null;
}
Cf-Access-Authenticated-User-Email is set by Cloudflare’s edge after Access validates the JWT. The Function trusts it because there is no path to set that header from outside the Access perimeter in production.
X-Dev-User is the local-dev bypass. It only works when env.ENVIRONMENT !== "production", which is a literal string check against a variable set in wrangler.jsonc. In production the env var is "production", the dev header is ignored, and the only way to authenticate is through Access. In dev the Function trusts whatever email the SPA puts in X-Dev-User (read from VITE_DEV_USER_EMAIL), which is exactly what I want when there is no real Access in front of wrangler pages dev.
What this buys ¶
A photographer can upload a 25 MB RAW directly to Cloudflare Images from a phone over a flaky connection. The only thing my Pages Function does during that upload is sit idle waiting for the third call to come back. The CPU time on the Function side is measured in milliseconds per upload no matter how big the file is.
It is the single biggest “right primitive for the job” decision in this project. The pattern works for anything where the user is sending bytes to a storage service: R2 has a similar presigned-URL story, S3 has had one forever. Whenever you see code that streams a multi-MB body through your application server, ask whether the storage layer wants to handle it directly.
In this series ¶
- Building a photography portfolio on Cloudflare’s full stack. The stack overview: Pages, D1, Images, Access, and Workers AI.
- Two sites, one codebase. The deploy-pages.mjs indirection, per-site D1, and Gitea CI.
- Cloudflare Images flexible delivery and retina srcSet. Variant setup, srcSet generation, and the original-download endpoint.
- Direct Creator Upload from the browser. Why a Pages Function should not proxy your image bytes, and what the three-request handshake looks like in DevTools. (this post)
- Stripping EXIF before upload and backfilling existing photos. What gets stripped, what stays, and the backfill script.
- AI-assisted captions, alt text, and tags. The Workers AI vision-model dispatcher and where it helps versus where it confidently invents.
Try it ¶
The upload UX is admin-only, but the outcome is on the public side: every photo you see on either site arrived this way.
Questions or “have you tried this differently” notes welcome on LinkedIn .