September 16, 2026

How to handle large file uploads in React

Tharun Budidha
Tharun Budidha
Full stack developer

A React file input hands you a File object, and fetch will happily send it. That path holds right up to the point where someone uploads a two-hour screen recording from a hotel network.

TL;DR

A single fetch makes the whole request the unit of failure, so a drop at 90 percent costs 90 percent of the transfer. Chunked upload splits the file and has the storage layer acknowledge each piece, which is what makes resuming from the last confirmed byte possible. In React that means giving <FastPixUploader> an endpoint function rather than a URL string, so a signed URL is minted when a file is picked rather than on every render. The file bytes then go straight from the browser to storage, and your own server never carries them.

Why a single-request React file upload stops working

One fetch call carries the whole file in one HTTP request, so the request is the unit of failure. Lose the connection at 90 percent and you lose 90 percent of the work. There's no protocol-level way to tell the server which bytes already arrived.

Three separate ceilings sit on top of that. Your reverse proxy caps request body size, and nginx defaults to 1 MB until someone raises client_max_body_size. Serverless platforms cap both body size and execution time, so a slow uploader trips the timeout before the bytes finish. And routing file bytes through your own API server means paying for bandwidth twice and holding a worker open for the duration.

Upload approachFailure costs youProgress reportingServer bandwidth
fetch with FormDataThe entire fileNoneFull file, twice
XMLHttpRequest with FormDataThe entire fileYes, via upload.onprogressFull file, twice
Chunked upload to a signed URLOne chunkYes, per chunkZero

The third row is what production video uploaders do. The rest of this walks through building it.

What you need before you start

  • A React 18 or later app. The examples use Vite, but nothing here's Vite-specific.
  • Node.js and a backend you control. A Vite app is entirely client-side, so it can't hold a secret or receive a webhook.
  • A FastPix account with an Access Token ID and Secret Key, from activate your account. The free plan covers 10 videos and 100K streaming minutes a month, with no credit card.

Step 1: The single-request upload, and its ceiling

Start with the version every React file upload tutorial shows. It's the right answer for avatars, CSVs and PDFs:

text
export default function SimpleUpload() {
  async function handleChange(event) {
    const file = event.target.files?.[0];
    if (!file) return;

    const body = new FormData();
    body.append("file", file);

    await fetch("/api/upload", { method: "POST", body });
  }

  return <input type="file" onChange={handleChange} />;
}

Under about 100 MB on a stable connection this is fine. Anything heavier is over-engineering. Above that the arithmetic turns against you. A 2 GB file on a 10 Mbps uplink takes roughly 27 minutes in one request. Most proxies, load balancers and serverless functions won't hold a connection open that long.

Step 2: Split the file into chunks

Chunking changes the unit of failure from the file to the chunk. The browser slices the File with File.slice(), uploads each slice as its own request, and retries only the slice that failed.

You can write that yourself. The slicing isn't the part that takes the time. The retry backoff is, along with the offline and online transitions. So is the byte offset bookkeeping after a pause, and the case where a chunk lands on the server but its response never reaches the client. The FastPix resumable uploads SDK handles those. It's plain JavaScript, with no React dependency:

bash
npm install @fastpix/resumable-uploads

You can run this against a real upload before committing to it. The free plan covers 10 videos and 100K streaming minutes a month with no credit card.

text
import { Uploader } from "@fastpix/resumable-uploads";

const fileUpload = Uploader.init({
  endpoint: signedUrl,   // created on your server, see Step 3
  file: video,
  chunkSize: 5120,       // KB. Minimum 5120, default 16 MB
});

fileUpload.on("progress", (event) => setProgress(event.detail.progress));
fileUpload.on("success", () => setDone(true));
fileUpload.on("error", (event) => setError(event.detail.message));

Chunks retry five times by default with exponential backoff, and pause(), resume() and abort() are methods on the returned instance. The full event list, including offline, online and chunkAttemptFailure, is in the resumable uploads guide. For the protocol-level explanation of why chunk size matters, we wrote that up separately in uploading large video files with chunking.

Step 3: Keep the credentials on your server

The chunks in Step 2 go to a signed URL, not to your API. Creating that URL needs your Secret Key, which means it belongs on a server. In a Vite app this isn't optional. Vite inlines every VITE_* environment variable into the client bundle, so a Secret Key with that prefix ships to every visitor.

javascript
// server.js
import express from "express";
import { Fastpix } from "@fastpix/fastpix-node";

const fastpix = new Fastpix({
  security: {
    username: process.env.FASTPIX_USERNAME,
    password: process.env.FASTPIX_PASSWORD,
  },
});

const app = express();

app.post("/api/upload-url", async (_req, res) => {
  const upload = await fastpix.inputVideo.upload(
    { corsOrigin: "*", pushMediaSettings: { accessPolicy: "public" } },
    { headers: { "X-Client-Type": "web-browser" } },
  );

  res.json({ url: upload.data.url, mediaId: upload.data.uploadId });
});

app.listen(8787);

The X-Client-Type: web-browser header is the one people miss. It tells FastPix that a browser will push the bytes, so the signed URL is issued for browser use. Leave it out and uploads fail with error code 0 after five attempts, while the media still processes. That symptom is confusing enough to have its own entry in the React (Vite) guide. The rule is about who sends the bytes, not who asks for the URL.

Keep the mediaId that comes back. It identifies the asset everywhere else in the API.

Step 4: Or skip the wiring and use the component

Everything above is available as one React component. <FastPixUploader> wraps the same upload engine. It adds the file picker, drag and drop, the progress track, and pause, resume and cancel controls:

bash
npm install @fastpix/fp-react-uploader
text
import { FastPixUploader } from "@fastpix/fp-react-uploader";
import "@fastpix/fp-react-uploader/styles.css";

async function createUpload() {
  const response = await fetch("/api/upload-url", { method: "POST" });
  const { url } = await response.json();
  return url;
}

export default function Upload() {
  return <FastPixUploader endpoint={createUpload} accept="video/*" />;
}

Passing a function to endpoint rather than a string matters more than it looks. The function runs when a file is picked, so a URL is minted per upload rather than per page load. A user who opens the page and walks away doesn't burn one. Layout is yours if you want it. Render FastPixDropZone, FastPixTrack, FastPixStatus and the control buttons as children, and arrange them however you like. The React uploader README documents the lot.

What changes when the file is video

An image upload is finished when the bytes arrive. A video upload isn't, and this is the single biggest difference between a React file upload and a React video upload.

The file still has to be probed and transcoded into a ladder of renditions. Then packaged into HLS segments and given a manifest, before any browser can play it reliably. So onSuccess means the bytes are delivered, not that the video is watchable. Treating those as the same event produces the bug where a user uploads, sees a success message, clicks play and gets a 404.

The state you actually want arrives as a webhook:

javascript
import crypto from "node:crypto";

app.post("/api/fastpix/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const signature = req.header("FastPix-Signature");
  const secret = process.env.FASTPIX_WEBHOOK_SECRET;

  if (!secret || !signature) {
    return res.status(400).json({ message: "invalid signature" });
  }

  const expected = crypto
    .createHmac("sha256", Buffer.from(secret, "base64"))
    .update(req.body)
    .digest("base64");

  const a = Buffer.from(expected);
  const bSig = Buffer.from(signature);

  if (a.length !== bSig.length || !crypto.timingSafeEqual(a, bSig)) {
    return res.status(400).json({ message: "invalid signature" });
  }

  const { type, data } = JSON.parse(req.body.toString());

  if (type === "video.media.ready") {
    await markVideoReady(data.id, data.playbackIds[0].id);
  }

  res.json({ message: "ok" });
});

express.raw() gives you a Buffer, not an object, so destructuring type off it yields undefined, the ready branch never runs, and the handler still answers 200. Both ends think the delivery worked. Hash the Buffer, then JSON.parse it.

The Signing Secret from the dashboard is Base64, and the HMAC key is its decoded bytes. Guard the empty case too: with no secret configured, an empty key verifies anything anyone sends you. Compare the lengths before timingSafeEqual, which throws rather than returning false when they differ. Setup is in set up webhooks.

Webhooks can't reach localhost, so during development either tunnel your server or poll the media directly with fastpix.manageVideos.get({ mediaId }) and check for status === "Ready".

Ship a resumable upload in your React app

Install @fastpix/fp-react-uploader, add the one Express route from Step 3, and point the component's endpoint at it. The first upload you run returns a mediaId immediately and a playback ID on the video.media.ready webhook a few moments later. The free plan covers 10 videos and 100K streaming minutes a month, no credit card. Push real files through the pipeline before you decide anything.

Frequently Asked Questions (FAQs)

How do I upload large files in React?

Split the file into chunks with File.slice() and upload each chunk as its own request. A network failure then costs one chunk instead of the whole file. Send the chunks to a signed URL created on your server rather than through your own API, which keeps file bytes off your infrastructure. @fastpix/resumable-uploads handles chunking, retries and resume, with a default chunk size of 16 MB and a 5 MB minimum.

What is the maximum file size for a React file upload?

React itself imposes no limit. The browser hands your component a File reference rather than the file contents. The real caps come from your infrastructure: nginx defaults client_max_body_size to 1 MB, and serverless platforms cap both request body size and execution time. Uploading directly to a signed URL sidesteps all three because the bytes never touch your server.

Can I show upload progress with fetch?

No. The fetch API has no upload progress event, so a fetch-based upload can only report "in flight" or "finished". XMLHttpRequest exposes upload.onprogress with loaded and total byte counts, which is why progress bars still use it. Chunked uploaders report progress per completed chunk instead.

Do I need a backend for a React file upload?

For anything signed, yes. The signing credential cannot live in the browser, and in Vite, a VITE_-prefixed variable is inlined into the client bundle and served to every visitor. The backend can be tiny: one route that mints an upload URL and one that receives webhooks.

How do I know when an uploaded video is ready to play?

Wait for the video.media.ready webhook. It fires after encoding finishes and carries the playback ID in data.playbackIds[0].id. The upload component's onSuccess callback fires earlier, when the last byte lands, and the video is not playable at that moment. In development, where webhooks cannot reach localhost, poll the media and check for status === "Ready".

Reporting that progress honestly is its own problem because fetch cannot measure it at all. Tracking upload progress in React, and why fetch can't covers the difference between buffered and confirmed bytes.

Share

Stay Ahead of Video
Streaming Trends

Start shipping video today.