<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: IderaDevTools</title>
    <description>The latest articles on DEV Community by IderaDevTools (@ideradevtools).</description>
    <link>https://dev.to/ideradevtools</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F692047%2Fa74c3570-fc25-4d45-89cb-8c37071e8a0f.jpg</url>
      <title>DEV Community: IderaDevTools</title>
      <link>https://dev.to/ideradevtools</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ideradevtools"/>
    <language>en</language>
    <item>
      <title>FastAPI Upload File with Multipart Handling and Streaming to Storage</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Thu, 10 Sep 2026 19:14:11 +0000</pubDate>
      <link>https://dev.to/ideradevtools/fastapi-upload-file-with-multipart-handling-and-streaming-to-storage-50c1</link>
      <guid>https://dev.to/ideradevtools/fastapi-upload-file-with-multipart-handling-and-streaming-to-storage-50c1</guid>
      <description>&lt;p&gt;A simple FastAPI endpoint using&amp;nbsp;&lt;code&gt;file: bytes&lt;/code&gt;&amp;nbsp;works fine for small demos. But try uploading a 2 GB video, and things can go wrong quickly. If your container only has 512 MB of memory, loading the whole file into memory can cause it to run out of space and crash.&lt;/p&gt;

&lt;p&gt;You may not even get a useful error. Sometimes, all you see is a memory-related message in the logs followed by a container restart.&lt;/p&gt;

&lt;p&gt;Most FastAPI upload file tutorials work fine until a large file hits a small container. FastAPI gives you a few ways to handle uploaded files, and each one uses memory differently.&lt;/p&gt;

&lt;p&gt;FastAPI uses&amp;nbsp;&lt;code&gt;UploadFile&lt;/code&gt;&amp;nbsp;to handle uploaded files without keeping the whole file in memory. Multipart data can be stored in a temporary file, which is much safer for large uploads.&lt;/p&gt;

&lt;p&gt;For large files, a good production setup is to validate the file early, stream it directly to object storage, and avoid loading the entire file into memory.&lt;/p&gt;

&lt;p&gt;Another option is to use a managed upload service like Filestack. This keeps the actual file transfer out of your API server, so your FastAPI app doesn’t have to handle the file bytes itself.&lt;/p&gt;

&lt;p&gt;This article covers each approach, starting with the simple buffered setup that can struggle under heavy uploads and ending with a setup that keeps the file transfer completely off your servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A&amp;nbsp;&lt;code&gt;bytes&lt;/code&gt;&amp;nbsp;parameter loads the entire upload into RAM.&amp;nbsp;&lt;code&gt;UploadFile&lt;/code&gt;&amp;nbsp;is the safer default for anything sizable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UploadFile&lt;/code&gt;&amp;nbsp;wraps&amp;nbsp;&lt;code&gt;SpooledTemporaryFile&lt;/code&gt;: small files stay in memory, large ones spill to disk on their own.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;request.stream()&lt;/code&gt;&amp;nbsp;yields chunks with no temp file at all, which keeps memory flat while you proxy to storage.&lt;/li&gt;
&lt;li&gt;Content-Length checks and part-size limits belong before you read the body, not after.&lt;/li&gt;
&lt;li&gt;A managed file upload api can take bytes out of your service entirely, leaving your endpoints to handle auth and metadata only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  UploadFile Under the Hood
&lt;/h2&gt;

&lt;p&gt;FastAPI gives you two ways to accept a file, and they behave very differently under load.&lt;/p&gt;

&lt;p&gt;Declare the parameter as&amp;nbsp;&lt;code&gt;bytes&lt;/code&gt;, and FastAPI reads the whole upload into memory before your function even runs. This works for small files and fails without warning as soon as someone uploads something large.&amp;nbsp;&lt;code&gt;UploadFile&lt;/code&gt;&amp;nbsp;is the answer most FastAPI docs point to for a general file upload api, and for good reason: it wraps Starlette’s&amp;nbsp;&lt;code&gt;SpooledTemporaryFile&lt;/code&gt;, which keeps small files in memory and spills larger ones to disk automatically, past a configurable threshold. Your endpoint code stays the same either way. Only where the bytes live changes.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from fastapi import FastAPI, UploadFile, File
app = FastAPI()
.post("/upload")
async def upload_file(file: UploadFile = File(...)):
contents = await file.read()
# process contents, or better, stream it in chunks below
await file.close()
return {"filename": file.filename, "size": len(contents)}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This basic form is fine for small files, and it’s already safer than a&amp;nbsp;&lt;code&gt;bytes&lt;/code&gt;&amp;nbsp;parameter since Starlette manages the spool for you. It still reads the full file into a variable at once, which is where the next section picks up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multipart Done Right
&lt;/h2&gt;

&lt;p&gt;A multipart request isn’t a single file. It’s a series of parts separated by a boundary string, and each part can hold a form field or a file, mixed in any order.&lt;/p&gt;

&lt;p&gt;Understanding how does multipart upload work in web applications helps explain why FastAPI needs a parser at all: the framework has to read the boundary, split the body into parts, and hand each one to the right parameter based on its name.&lt;/p&gt;

&lt;p&gt;FastAPI handles this parsing for you with&amp;nbsp;&lt;code&gt;UploadFile&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;Form&lt;/code&gt;, so most endpoints don’t need to work with the raw multipart request.&lt;/p&gt;

&lt;p&gt;If you’re asking how to add file uploads to a REST API, the basic approach is usually the same: accept the file as multipart form data, validate it early, and save it somewhere reliable before sending a response.&lt;/p&gt;

&lt;p&gt;The main difference between frameworks is how much of this work they handle for you.&lt;/p&gt;

&lt;p&gt;Set limits on file size and the number of parts before processing the upload. An unlimited number of file parts can use up memory or disk space, even when each file is small.&lt;/p&gt;

&lt;p&gt;Multipart parsing gets the file into your endpoint. The next challenge is sending it somewhere else without using too much memory. That’s what we’ll cover next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming Straight to Storage
&lt;/h2&gt;

&lt;p&gt;Reading the entire file into a variable still means your app has to hold the full file while processing it. For large uploads, that’s not ideal.&lt;/p&gt;

&lt;p&gt;A better approach is to stream the file directly to storage in small chunks. With&amp;nbsp;&lt;code&gt;request.stream()&lt;/code&gt;, you can read the request body piece by piece without creating a temporary file.&lt;/p&gt;

&lt;p&gt;If you’re uploading to your own S3 bucket, the flow is simple: read one chunk, send it as an S3 multipart upload part, then move to the next chunk. You only keep one chunk in memory at a time, so memory usage stays much more predictable even as file sizes grow.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import boto3
from fastapi import FastAPI, Request
app = FastAPI()
s3 = boto3.client("s3")
BUCKET = "your-upload-bucket"
CHUNK_SIZE = 5 * 1024 * 1024  # 5MB, S3's minimum part size
u/app.post("/upload-stream/{key}")
async def upload_stream(key: str, request: Request):
upload = s3.create_multipart_upload(Bucket=BUCKET, Key=key)
upload_id = upload["UploadId"]
parts = []
part_number = 1
buffer = b""
try:
async for chunk in request.stream():
buffer += chunk
while len(buffer) &amp;gt;= CHUNK_SIZE:
part_data, buffer = buffer[:CHUNK_SIZE], buffer[CHUNK_SIZE:]
result = s3.upload_part(
Bucket=BUCKET, Key=key, UploadId=upload_id,
PartNumber=part_number, Body=part_data,
)
parts.append({"PartNumber": part_number, "ETag": result["ETag"]})
part_number += 1
if buffer:
result = s3.upload_part(
Bucket=BUCKET, Key=key, UploadId=upload_id,
PartNumber=part_number, Body=buffer,
)
parts.append({"PartNumber": part_number, "ETag": result["ETag"]})
s3.complete_multipart_upload(
Bucket=BUCKET, Key=key, UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
except Exception:
s3.abort_multipart_upload(Bucket=BUCKET, Key=key, UploadId=upload_id)
raise
return {"key": key, "parts": len(parts)}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This approach adds a little more code, but it gives you an important benefit: memory usage stays close to&amp;nbsp;&lt;code&gt;CHUNK_SIZE&lt;/code&gt;, no matter how large the file is.&lt;/p&gt;

&lt;p&gt;That only works if you validate the incoming request first. So the next step is making sure those checks are in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, Validation and Errors
&lt;/h2&gt;

&lt;p&gt;Streaming protects your memory while the upload is running, but it doesn’t stop a bad request from starting. You should validate the request before reading any file data.&lt;/p&gt;

&lt;p&gt;First, check&amp;nbsp;&lt;code&gt;Content-Length&lt;/code&gt;&amp;nbsp;against your maximum file size. If the header is missing or can’t be trusted, keep checking the size as you read each chunk and stop as soon as the limit is reached.&lt;/p&gt;

&lt;p&gt;You should also check the actual file type. Don’t rely only on the file extension or the MIME type sent by the client because those can be wrong.&lt;/p&gt;

&lt;p&gt;These same limits apply to any REST API that handles file uploads, no matter which framework you use. FastAPI simply gives you the tools to check them early.&lt;/p&gt;

&lt;p&gt;Once your own upload endpoint is properly limited and validated, there’s another option: don’t handle the file upload yourself at all. That’s the next approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Managed Route, Off the Data Path
&lt;/h2&gt;

&lt;p&gt;The final option is to keep the actual file upload out of FastAPI completely. Instead, clients upload directly through a managed&lt;a href="https://www.filestack.com/" rel="noopener noreferrer"&gt;&amp;nbsp;file upload API&lt;/a&gt;, while your FastAPI endpoints handle things like authentication and file metadata.&lt;/p&gt;

&lt;p&gt;Your server can provide short-lived upload credentials; the client sends the file directly to storage, and your API saves the file details once the upload is complete.&lt;/p&gt;

&lt;p&gt;The main benefit is that the file data never passes through your FastAPI container, which keeps your server lighter and reduces memory and bandwidth pressure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0hax6gpmiy2jh8joxc6p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0hax6gpmiy2jh8joxc6p.png" alt=" " width="799" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you don’t want to build and maintain your own file upload infrastructure, a managed service can handle that part for you.&lt;/p&gt;

&lt;p&gt;Filestack’s REST API supports file uploads up to&amp;nbsp;&lt;strong&gt;5 GB&lt;/strong&gt;&amp;nbsp;and uses chunked uploads for large files. This lets your FastAPI service focus on things like authentication and file metadata instead of moving the actual file data.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import requests
FILESTACK_API_KEY = "YOUR_API_KEY"
def get_upload_url(filename: str) -&amp;gt; dict:
response = requests.post(
f"&amp;lt;https://www.filestackapi.com/api/store/S3?key={FILESTACK_API_KEY}&amp;gt;",
params={"filename": filename},

)

response.raise_for_status()

return response.json()  # contains the URL the client uploads to directly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Your endpoint only needs to provide the upload URL and save the returned file handle after the upload succeeds. The actual file transfer happens outside your service.&lt;/p&gt;

&lt;p&gt;Now that we’ve covered all four approaches, here’s a quick summary of what to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Choose Your Memory Tier on Purpose
&lt;/h2&gt;

&lt;p&gt;FastAPI gives you four main ways to handle file uploads. The key difference is where the file data goes while it’s being uploaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Buffer&lt;/strong&gt;&amp;nbsp;keeps the whole file in memory.&amp;nbsp;&lt;strong&gt;Spool&lt;/strong&gt;, which is what&amp;nbsp;&lt;code&gt;UploadFile&lt;/code&gt;&amp;nbsp;uses, keeps smaller files in memory and moves larger ones to disk.&amp;nbsp;&lt;strong&gt;Stream&lt;/strong&gt;&amp;nbsp;sends the file in chunks directly to storage, keeping memory usage low.&amp;nbsp;&lt;strong&gt;Bypass&lt;/strong&gt;&amp;nbsp;skips your server completely and lets the client upload directly to storage.&lt;/p&gt;

&lt;p&gt;Choose the approach based on your file sizes and infrastructure instead of waiting for a large upload to crash your container.&lt;/p&gt;

&lt;p&gt;A good first step is to replace any&amp;nbsp;&lt;code&gt;bytes&lt;/code&gt;&amp;nbsp;parameter that is still being used for real file uploads.&lt;/p&gt;

&lt;blockquote&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;This article was published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/fastapi-upload-file-multipart-streaming/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>file</category>
      <category>upload</category>
      <category>fastapi</category>
    </item>
    <item>
      <title>How to Upload JPG File on Mobile with Orientation, Size and Format Traps</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Wed, 09 Sep 2026 10:45:00 +0000</pubDate>
      <link>https://dev.to/ideradevtools/how-to-upload-jpg-file-on-mobile-with-orientation-size-and-format-traps-j8i</link>
      <guid>https://dev.to/ideradevtools/how-to-upload-jpg-file-on-mobile-with-orientation-size-and-format-traps-j8i</guid>
      <description>&lt;p&gt;Three common problems can show up in the same sprint. An avatar appears sideways on Android. A 12 MB photo takes too long to upload on a weak connection. And an iPhone photo fails validation because it isn’t actually a JPG.&lt;/p&gt;

&lt;p&gt;Most guides on how to upload JPG files on mobile stop once the file is selected. But that’s where the real problems often begin.&lt;/p&gt;

&lt;p&gt;The photo might arrive rotated, be much larger than needed, or turn out to be a different format. Each of these problems has a clear cause and a simple fix.&lt;/p&gt;

&lt;p&gt;To upload a JPG file on mobile, let users pick from camera or gallery, then handle the three traps that break JPG uploads: EXIF orientation (photos arriving sideways), oversized camera outputs (often 5–12MB), and format mismatches such as HEIC masquerading as JPG on iOS. Client-side resize plus server-side normalisation solves all three. Filestack converts, rotates, and compresses automatically on upload.&lt;/p&gt;

&lt;p&gt;This article looks at each problem separately and then shows how to handle all of them in one step. That way, you don’t have to fix each upload bug as it appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;EXIF Orientation values 3, 6, and 8 mark a rotated capture. Ignore them, and the photo displays sideways or upside down.&lt;/li&gt;
&lt;li&gt;Phone cameras commonly output 5 to 12MB JPGs at 12 to 48 megapixels, far more than any screen needs to display.&lt;/li&gt;
&lt;li&gt;iOS saves photos as HEIC by default. A file named&amp;nbsp;&lt;code&gt;photo.jpg&lt;/code&gt;&amp;nbsp;is not proof that it holds JPG data.&lt;/li&gt;
&lt;li&gt;A canvas resize on the client fixes orientation and file size in one step, since the redrawn pixels come out upright.&lt;/li&gt;
&lt;li&gt;Server-side normalisation catches every client you don’t control, including third-party apps and old app versions still in the wild.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trap 1, The Sideways Photo (EXIF Orientation)
&lt;/h2&gt;

&lt;p&gt;Phone cameras don’t rotate the pixel data when you turn the phone. They save the image as captured and write a rotation instruction into the EXIF metadata instead. Most photo apps read that instruction and display the photo the right way up. Plenty of image libraries and browsers don’t, and that’s when a portrait selfie shows up lying on its side in your app.&lt;/p&gt;

&lt;p&gt;The Orientation tag can hold several values, but three of them cause almost every sideways bug: values 3, 6, and 8 mark a rotated capture, corresponding to 180, 90, and 270 degrees. If your upload pipeline ignores this tag, the photo saves and displays exactly as rotated.&lt;/p&gt;

&lt;p&gt;The fix is to draw the image onto a canvas using the correct rotation before you upload it. Once it’s drawn, the pixels themselves are upright, so no downstream viewer can get it wrong again.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function drawUprightImage(file) {
return new Promise((resolve) =&amp;gt; {
const img = new Image();
const reader = new FileReader();
reader.onload = (e) =&amp;gt; {
img.onload = () =&amp;gt; {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
getOrientation(file, (orientation) =&amp;gt; {
const swap = orientation &amp;gt;= 5 &amp;amp;&amp;amp; orientation &amp;lt;= 8;
canvas.width = swap ? img.height : img.width;
canvas.height = swap ? img.width : img.height;
switch (orientation) {
case 3: ctx.transform(-1, 0, 0, -1, canvas.width, canvas.height); break;
case 6: ctx.transform(0, 1, -1, 0, canvas.height, 0); break;
case 8: ctx.transform(0, -1, 1, 0, 0, canvas.width); break;
default: break;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) =&amp;gt; resolve(blob), 'image/jpeg', 0.9);
});
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Fixing orientation on the client helps, but it only covers the clients you control. Keep that in mind while we move to the next trap, which shares part of the same fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2, The 12MB Camera File
&lt;/h2&gt;

&lt;p&gt;A modern phone camera shoots at 12 to 48 megapixels and saves the result as a JPG in the 5 to 12MB range. Almost nothing in your app needs that much resolution. A profile photo displays at a few hundred pixels wide. Even a full-screen image rarely needs more than 2000 pixels on its longest edge.&lt;/p&gt;

&lt;p&gt;Uploading the full-size image uses extra time and data without giving you much benefit. It’s really a page-size problem, just happening during the upload.&lt;/p&gt;

&lt;p&gt;The same rule used to speed up image loading applies here too: resize the image to the size you actually need before uploading it.&lt;/p&gt;

&lt;p&gt;Resize on the client before the upload starts. Canvas resize also solves this in the same pass as the orientation fix above, since you’re already redrawing the image. Set a maximum dimension, scale the canvas to fit it, and export at a reasonable JPEG quality like 0.8 or 0.9. A 10MB original commonly comes out under 1MB with no visible loss on a phone screen.&lt;/p&gt;

&lt;p&gt;Resizing the image on the device takes care of the file size before upload. The next problem is a little trickier because the file may look completely normal at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3, HEIC in JPG Clothing
&lt;/h2&gt;

&lt;p&gt;iOS stores photos as HEIC by default, not JPG. Some pickers and share sheets hand the file over with a&amp;nbsp;&lt;code&gt;.jpg&lt;/code&gt;&amp;nbsp;extension anyway, or a name that looks like a JPG, while the actual bytes are still HEIC. A file extension is a label someone chose. It is not proof of what’s inside the file.&lt;/p&gt;

&lt;p&gt;Trusting the extension is how format bugs make it to production undetected in testing. The safest check is the byte signature at the start of the file, not the name. JPG files start with the bytes FF D8 FF. HEIC files carry a different signature entirely. Check the actual bytes, and convert if the signature doesn’t match what the extension claims.&lt;/p&gt;

&lt;p&gt;This is where image transformation pipelines can make things easier. A common question is how to resize, crop, watermark, or change an image format on the fly.&lt;/p&gt;

&lt;p&gt;Instead of processing the same image several times, you can use one&amp;nbsp;&lt;a href="https://www.filestack.com/products/transformations/" rel="noopener noreferrer"&gt;transformation URL&lt;/a&gt;&amp;nbsp;to handle multiple changes in a single request. Filestack can use the image’s EXIF data to correct its orientation, convert HEIC images to JPG or WebP, and compress the image in the same transformation chain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6yq78eecfv37rn80nz7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6yq78eecfv37rn80nz7.png" alt=" " width="799" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now that we’ve covered all three problems, the next question is where to handle each one in your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation, Web Form and Native
&lt;/h2&gt;

&lt;p&gt;On mobile web, image uploads usually start with a file input. A common question is how to add image uploads to a web form. You can use&amp;nbsp;&lt;code&gt;&amp;lt;input type="file" accept="image/*" capture="environment"&amp;gt;&lt;/code&gt;&amp;nbsp;to open the camera directly. If you remove&amp;nbsp;&lt;code&gt;capture&lt;/code&gt;, users can choose between the camera and gallery.&lt;/p&gt;

&lt;p&gt;Native apps work a little differently. Both iOS and Android have SDKs that can handle camera and gallery permissions, so you don’t have to build that flow yourself.&lt;/p&gt;

&lt;p&gt;React Native has its own approach. Image picker libraries return a local file URI instead of a browser File object, so your upload code needs to read the file from that URI first.&lt;/p&gt;

&lt;p&gt;The fixes for orientation and file size stay the same across platforms. Only the way you get the file changes.&lt;/p&gt;

&lt;p&gt;You can build all of this yourself, but that means maintaining similar upload logic for web, iOS, Android, and React Native. There is a simpler way to handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Managed Route, Normalise on Ingestion
&lt;/h2&gt;

&lt;p&gt;You can handle all three problems with a single&lt;a href="https://www.filestack.com/products/file-upload/" rel="noopener noreferrer"&gt;&amp;nbsp;mobile file upload&lt;/a&gt;&amp;nbsp;flow that automatically fixes orientation, converts formats, and compresses images as they are uploaded.&lt;/p&gt;

&lt;p&gt;Instead of writing separate code for image rotation on the web, native SDKs, and HEIC checks, you can use one transformation process for every file as soon as it arrives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wjn7z7ehki9cr9vjlyv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wjn7z7ehki9cr9vjlyv.png" alt=" " width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Server-side normalisation also handles files from places you don’t control. Older app versions, third-party integrations, and API requests might skip your client-side fixes. A server-side step catches these files too and makes sure they follow the same rules.&lt;/p&gt;

&lt;p&gt;Once the file is normalised, you can create different image sizes for your app. This leads to another common question: how can you generate thumbnails automatically after an upload?&lt;/p&gt;

&lt;p&gt;You can create thumbnails, medium previews, and full-size versions from the same normalised image. This is much simpler than running a separate resize job for each version.&lt;/p&gt;

&lt;blockquote&gt;
&lt;/blockquote&gt;

&lt;p&gt;We’ve covered how to fix each problem. Here’s a quick summary to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Trust Bytes, Not Extensions
&lt;/h2&gt;

&lt;p&gt;Three traps, three fixes: orient from EXIF instead of trusting the file as captured, resize early instead of uploading the full camera output, and convert by byte signature instead of trusting the file extension. Client-side resize handles the traffic you can see. Server-side normalisation catches everything else.&lt;/p&gt;

&lt;p&gt;Run a real phone photo through a transformation sandbox and check all three: does it come out upright, does it come out at a sane file size, and does it come out as an actual JPG regardless of what the original claimed to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why do my mobile photo uploads appear sideways?
&lt;/h2&gt;

&lt;p&gt;EXIF Orientation is being ignored somewhere in the pipeline. Auto-orient on ingestion, or draw the image upright client-side using the Orientation tag before upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did an iPhone JPG upload fail validation?
&lt;/h2&gt;

&lt;p&gt;It was likely HEIC with a JPG-style name. Check the byte signature rather than the file extension, and convert HEIC to JPG or WebP if the signature doesn’t match.&lt;/p&gt;

&lt;h2&gt;
  
  
  How big are phone camera JPGs?
&lt;/h2&gt;

&lt;p&gt;Commonly 5 to 12MB at 12 to 48 megapixels. Resize to the display target before or during upload rather than sending the original file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;This article was published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/upload-jpg-file-mobile-traps/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>upload</category>
      <category>jpg</category>
    </item>
    <item>
      <title>How to Build a Scalable Image Upload Component with Shadcn UI</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:10:02 +0000</pubDate>
      <link>https://dev.to/ideradevtools/how-to-build-a-scalable-image-upload-component-with-shadcn-ui-198o</link>
      <guid>https://dev.to/ideradevtools/how-to-build-a-scalable-image-upload-component-with-shadcn-ui-198o</guid>
      <description>&lt;p&gt;If you search for a Shadcn UI image upload component, you’ll quickly notice there isn’t one. That’s intentional.&lt;/p&gt;

&lt;p&gt;You might try running&amp;nbsp;&lt;code&gt;npx shadcn add upload&lt;/code&gt;, only to find that no upload component exists. Then you end up on the same GitHub discussions as many other developers asking the same question: Where is the image upload component?&lt;/p&gt;

&lt;p&gt;The honest answer is: you build it yourself.&lt;/p&gt;

&lt;p&gt;At first, that might seem surprising, but it actually makes sense. Shadcn UI isn’t a library where you install ready-made components. Instead, it gives you building blocks that you add to your own codebase and customise as needed.&lt;/p&gt;

&lt;p&gt;Image upload is different for every application. The upload flow, states, and backend integration can vary a lot, so a single upload component wouldn’t work for every use case.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Shadcn UI ships no built-in image upload component; you compose one from its primitives (Button, Card, Progress, Dialog) around an upload engine that handles files, previews, and errors. The clean split is shadcn for presentation and a dedicated uploader for transfer. Filestack’s React SDK slots in as that engine, keeping the shadcn look while adding chunked, resumable uploads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this guide, we’ll build an image upload component step by step. We’ll use Shadcn UI to create the interface, add state management and validation, and then connect it to an upload service.&lt;/p&gt;

&lt;p&gt;The best part is that you can change the upload provider later without rebuilding the UI. The interface stays the same while the upload logic can be swapped whenever you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Shadcn/ui has no upload component on purpose. You compose one from Card, Button, Progress, and Dialog.&lt;/li&gt;
&lt;li&gt;A working upload UI needs four pieces: a dropzone surface, file rows, a progress bar per row, and error text inside the row, not a toast.&lt;/li&gt;
&lt;li&gt;A hidden&amp;nbsp;&lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;&amp;nbsp;paired with a&amp;nbsp;&lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;&amp;nbsp;keeps the dropzone accessible and keyboard operable.&lt;/li&gt;
&lt;li&gt;Keep transfer logic behind a small interface. Swapping&amp;nbsp;&lt;code&gt;fetch()&lt;/code&gt;&amp;nbsp;for a real upload SDK should change zero markup.&lt;/li&gt;
&lt;li&gt;Filestack’s React SDK maps its progress callbacks directly onto Shadcn’s&amp;nbsp;&lt;code&gt;&amp;lt;code&amp;gt;Progress&lt;/code&gt;&amp;nbsp;component, so the engine and the UI stay decoupled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before building anything, it’s worth understanding why this gap exists, because it shapes every decision after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Shadcn Does Not Ship an Uploader
&lt;/h2&gt;

&lt;p&gt;Shadcn’s whole philosophy is “copy the code, own the code.” That works well for a button or a dialog, because those components don’t hold much internal state. An uploader is different. It has to track file selection, per-file progress, retries, validation errors, and network failures, all at once. Trying to template that into one drop-in component would mean baking in assumptions about your backend, your file size limits, and your error handling, exactly the kind of lock-in shadcn tries to avoid.&lt;/p&gt;

&lt;p&gt;So instead of asking “what are the best React components for file uploading,” the more useful question becomes “which primitives do I already have, and what’s missing?” As it turns out, you already have most of what you need. What’s missing is the part that actually talks to a server.&lt;/p&gt;

&lt;p&gt;With that context in place, let’s start stacking primitives into an actual dropzone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composing the Surface: Dropzone plus Rows plus Progress
&lt;/h2&gt;

&lt;p&gt;This is where shadcn earns its keep. A React drag-and-drop file upload surface and a React file upload component for the file list are really the same composition problem, just two different views of it.&lt;/p&gt;

&lt;p&gt;The dropzone itself is a Card wrapping a hidden file input and a label. The label pattern matters here: clicking anywhere on the label opens the file picker, and because it’s a real form control under the hood, keyboard users can tab to it and hit Enter or Space to open it too. Below the dropzone, each selected file becomes its own row, and each row gets its own Progress bar.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
export function ImageUploader({ files, onFilesSelected, onRetry }) {
return (
&amp;lt;Card className="p-6"&amp;gt;
&amp;lt;label
htmlFor="file-input"
className={cn(
"flex flex-col items-center justify-center gap-3",
"rounded-lg border-2 border-dashed border-muted-foreground/30",
"py-10 text-center cursor-pointer hover:border-primary/50"
)}
&amp;gt;
&amp;lt;span className="font-medium"&amp;gt;Drag and drop images here&amp;lt;/span&amp;gt;
&amp;lt;span className="text-sm text-muted-foreground"&amp;gt;or click to browse&amp;lt;/span&amp;gt;
&amp;lt;input
id="file-input"
type="file"
multiple
accept="image/*"
className="sr-only"
onChange={(e) =&amp;gt; onFilesSelected(Array.from(e.target.files))}
/&amp;gt;
&amp;lt;/label&amp;gt;
&amp;lt;ul className="mt-6 space-y-3"&amp;gt;
{files.map((file) =&amp;gt; (
&amp;lt;li key={file.id} className="rounded-md border p-3"&amp;gt;
&amp;lt;div className="flex items-center justify-between text-sm"&amp;gt;
&amp;lt;span className="font-medium"&amp;gt;{file.name}&amp;lt;/span&amp;gt;
{file.status === "failed" ? (
&amp;lt;button
onClick={() =&amp;gt; onRetry(file.id)}
className="text-destructive underline"
&amp;gt;
Retry
&amp;lt;/button&amp;gt;
) : (
&amp;lt;span className="text-muted-foreground"&amp;gt;{file.status}&amp;lt;/span&amp;gt;
)}
&amp;lt;/div&amp;gt;
&amp;lt;Progress value={file.progress} className="mt-2 h-2" /&amp;gt;
{file.error &amp;amp;&amp;amp; (
&amp;lt;p className="mt-1 text-xs text-destructive"&amp;gt;{file.error}&amp;lt;/p&amp;gt;
)}
&amp;lt;/li&amp;gt;
))}
&amp;lt;/ul&amp;gt;
&amp;lt;/Card&amp;gt;
);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26zmhgk8am619hjzlfea.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F26zmhgk8am619hjzlfea.png" alt=" " width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice the error text sits inside the row, right under that file’s own progress bar, instead of floating away in a toast. A toast disappears in a few seconds. A row stays put until the user deals with it, which matters when three out of twenty files failed, and you don’t want the person hunting for which ones.&lt;/p&gt;

&lt;p&gt;The surface is only half the job, though. Right now, none of this actually tracks state. Let’s fix that next.&lt;/p&gt;

&lt;h2&gt;
  
  
  State and Validation
&lt;/h2&gt;

&lt;p&gt;With the JSX in place, the component needs somewhere to keep track of what’s happening to each file, and a way to say no to files that shouldn’t be there in the first place.&lt;/p&gt;

&lt;p&gt;A&amp;nbsp;&lt;code&gt;useReducer&lt;/code&gt;&amp;nbsp;keyed by file ID works well here, since every file’s status changes independently of the others. Validation, checking file type and size, happens the moment a file is selected, before any request goes out. This answers a common early question too: file uploading in React JS almost always starts with this same shape, a reducer plus a validation step, no matter which transport ends up sending the bytes.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function uploadReducer(state, action) {
switch (action.type) {
case "ADD_FILES":
return {
...state,
...Object.fromEntries(
action.files.map((f) =&amp;gt; [f.id, { ...f, status: "queued", progress: 0 }])
),
};
case "PROGRESS":
return {
...state,
[action.id]: { ...state[action.id], status: "uploading", progress: action.pct },
};
case "DONE":
return { ...state, [action.id]: { ...state[action.id], status: "done", progress: 100 } };
case "ERROR":
return {
...state,
[action.id]: { ...state[action.id], status: "failed", error: action.message },
};
default:
return state;
}
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Validation lives right where files enter the component:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function validateFile(file) {
if (!file.type.startsWith("image/")) return "Only image files are allowed.";
if (file.size &amp;gt; 10 * 1024 * 1024) return "File is larger than 10MB.";
return null;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Most guides on implementing image uploads in React stop here, with a working component and simulated progress. That’s enough to show how the UI works.&lt;/p&gt;

&lt;p&gt;But in a real application, you also need something that uploads the file and reports the actual upload progress. That’s where choosing the right upload solution becomes important.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Engine Swap: fetch to SDK
&lt;/h2&gt;

&lt;p&gt;This is the piece that makes the whole composition worth the extra setup: the transport layer sits behind a small interface, so the shadcn component above never needs to know or care how bytes actually get to the server.&lt;/p&gt;

&lt;p&gt;Start with the interface itself. It only needs one method, and it only needs to report progress and completion:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// uploadEngine.js
export function createFetchEngine(endpoint) {
return {
upload(file, { onProgress, onDone, onError }) {
const xhr = new XMLHttpRequest();
const form = new FormData();
form.append("file", file);
xhr.upload.onprogress = (e) =&amp;gt; {
onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () =&amp;gt; (xhr.status &amp;lt; 300 ? onDone(xhr.response) : onError("Upload failed"));
xhr.onerror = () =&amp;gt; onError("Network error");
xhr.open("POST", endpoint);
xhr.send(form);
},
};
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;A React JS file upload component built this way already works. The catch is that raw fetch or&amp;nbsp;&lt;code&gt;XMLHttpRequest&lt;/code&gt;&amp;nbsp;gives you one request per file, no chunking, and no way to resume a large upload that drops halfway through. Swapping in&lt;a href="https://www.filestack.com/sdks/react/" rel="noopener noreferrer"&gt;&amp;nbsp;Filestack’s React SDK&lt;/a&gt;&amp;nbsp;as the engine keeps the exact same interface, but the internals now handle chunked, resumable transfer:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import { init } from "filestack-js";
export function createFilestackEngine(apiKey) {
const client = init(apiKey);
return {
upload(file, { onProgress, onDone, onError }) {
client
.upload(file, {
onProgress: (evt) =&amp;gt; onProgress(Math.round(evt.totalPercent)),
})
.then((res) =&amp;gt; onDone(res))
.catch((err) =&amp;gt; onError(err.message));
},
};
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1buhbe7s6ixjb0cjw9o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1buhbe7s6ixjb0cjw9o.png" alt=" " width="800" height="493"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both upload engines use the same&amp;nbsp;&lt;code&gt;upload(file, callbacks)&lt;/code&gt;&amp;nbsp;function, so you don’t need to change the component from Section 2. The only thing that changes is the upload engine you pass into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Managed Route: Shadcn Look, Production Engine
&lt;/h2&gt;

&lt;p&gt;Building your own fetch-based engine is a fine way to learn the shape of the problem, and it’s genuinely enough for a small internal tool. But once you need chunking for large images, resumable uploads on flaky connections, or reliable retry behaviour, that engine starts asking for real maintenance time.&lt;/p&gt;

&lt;p&gt;Keep the markup, upgrade the engine: wire the composed component to a production&lt;a href="https://www.filestack.com/products/file-upload/" rel="noopener noreferrer"&gt;&amp;nbsp;upload ui&lt;/a&gt;&amp;nbsp;and the same shadcn rows gain chunked transfer, retries, and 5GB file support, without a redesign. The&amp;nbsp;&lt;code&gt;onProgress&lt;/code&gt;&amp;nbsp;callback from the React SDK maps one-to-one onto the Progress value you’re already rendering, so the swap really is as small as it looks in the code above.&lt;/p&gt;

&lt;p&gt;This is also where teams building something like a profile picture uploader tend to land. The UI stays identical to a plain gallery upload, same dropzone, same rows, but the failure modes that matter for a single, important image (say, someone’s profile photo) get handled by the engine instead of a hand-rolled retry loop.&lt;/p&gt;

&lt;p&gt;If you’re curious how that plays out for single-file, high-stakes uploads, our&lt;a href="https://blog.filestack.com/file-upload-react-easy-tutorial/" rel="noopener noreferrer"&gt;&amp;nbsp;React file upload walkthrough&lt;/a&gt;&amp;nbsp;covers that shape in more depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Own the Pixels, Outsource the Packets
&lt;/h2&gt;

&lt;p&gt;The lesson underneath all of this is simpler than it looks in the code: shadcn was never going to ship an uploader, because uploading isn’t really a presentation problem. It’s a transport problem wearing a UI.&lt;/p&gt;

&lt;p&gt;Split the two apart, and both sides get easier. You keep full control over how the dropzone and rows look and feel, since that’s just your own JSX and Tailwind classes. And you keep the option to swap the engine underneath, from a quick fetch call to something built for chunking and retries, without ever touching that markup again.&lt;/p&gt;

&lt;p&gt;If you want to see it end to end, copy the composed component above and connect it to Filestack to see real progress values fill in those same progress bars.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;This article was published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/shadcn-ui-image-upload-component/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>image</category>
      <category>upload</category>
    </item>
    <item>
      <title>Profile Picture Upload UI with Cropping, Preview and Instant Feedback</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Thu, 27 Aug 2026 12:50:48 +0000</pubDate>
      <link>https://dev.to/ideradevtools/profile-picture-upload-ui-with-cropping-preview-and-instant-feedback-4p0n</link>
      <guid>https://dev.to/ideradevtools/profile-picture-upload-ui-with-cropping-preview-and-instant-feedback-4p0n</guid>
      <description>&lt;p&gt;Almost every team runs into this problem at some point. A user uploads a photo, carefully adjusts the crop, and clicks Save. But when their profile picture appears later, it’s cropped differently. Part of their face might be cut off, or the framing looks wrong.&lt;/p&gt;

&lt;p&gt;It may seem like a small issue, but users notice it right away because profile pictures are personal.&lt;/p&gt;

&lt;p&gt;The first few seconds after someone selects a profile picture matter the most. That’s when users decide if the upload experience feels smooth or frustrating. In that window, a good flow does four things: it lets the user select or drop a file, shows an instant local preview, offers a circular crop with zoom, and gives honest feedback while the image uploads and processes. The best implementations show the final cropped result before the network round trip even finishes, so what the user approves is what actually ships.&lt;/p&gt;

&lt;p&gt;That last part is the tricky bit. The preview, crop tool, and final uploaded image all need to use the same crop settings. If they don’t, the image users see before saving won’t match the one that gets uploaded.&lt;/p&gt;

&lt;p&gt;Let’s look at how to build this the right way, step by step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mnk871pr20hhyjst4ug.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1mnk871pr20hhyjst4ug.png" alt=" " width="720" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Show a local preview the instant a file is selected; don’t wait for the upload to start.&lt;/li&gt;
&lt;li&gt;Store the crop as a rectangle (coordinates), not just a rendered circle; you’ll need it again.&lt;/li&gt;
&lt;li&gt;Keep the crop math identical between what the user previews and what the server delivers.&lt;/li&gt;
&lt;li&gt;Break “uploading” into real states (previewing, uploading, processing, saved), so users trust the progress.&lt;/li&gt;
&lt;li&gt;Fix EXIF orientation before cropping, or phone photos will crop sideways.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now let’s take a quick look at why the preview needs to appear before anything touches the network, and how to wire that up for both drag-and-drop and standard file inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three-Second Rule: Instant Local Preview
&lt;/h2&gt;

&lt;p&gt;The moment someone picks a photo, they want to see it. Not after a spinner, not after a server round trip; they want to preview it immediately. Browsers make this easy with&amp;nbsp;&lt;code&gt;URL.createObjectURL()&lt;/code&gt;, which turns a local&amp;nbsp;&lt;code&gt;File&lt;/code&gt;&amp;nbsp;object into a temporary URL your&amp;nbsp;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;&amp;nbsp;tag can render right away, without the need for any upload.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function handleFileSelect(file) {
const previewUrl = URL.createObjectURL(file);
imgElement.src = previewUrl;
// Revoke later to free memory
imgElement.onload = () =&amp;gt; URL.revokeObjectURL(previewUrl);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This works whether the file arrives through a standard&amp;nbsp;&lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;&amp;nbsp;or a drag-and-drop zone. For drag and drop, you’re listening for&amp;nbsp;&lt;code&gt;drop&lt;/code&gt;&amp;nbsp;events and pulling the file off&amp;nbsp;&lt;code&gt;event.dataTransfer.files;&lt;/code&gt;&amp;nbsp;for a form input, it’s the&amp;nbsp;&lt;code&gt;change&lt;/code&gt;&amp;nbsp;event on the input element. Either path lands you the same&amp;nbsp;&lt;code&gt;File&lt;/code&gt;&amp;nbsp;object, so the preview logic doesn’t need to know which source it came from.&lt;/p&gt;

&lt;p&gt;One thing worth handling early: EXIF orientation. Phone cameras often store images sideways or upside down and rely on metadata to display them correctly. Browsers mostly respect this metadata for regular&amp;nbsp;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;&amp;nbsp;rendering, but once you start drawing to a canvas for cropping, that metadata can get ignored, and suddenly your crop preview is rotated 90 degrees from what the user expects. Correcting the image orientation before cropping helps ensure the final result matches the user’s selection.&lt;/p&gt;

&lt;p&gt;With the preview solved, the next question is what the user does with it, and that’s where cropping comes in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Crop, Zoom and the Circle Mask
&lt;/h2&gt;

&lt;p&gt;Most avatar UIs show a circular preview, but the circle is a mask, not the actual crop. Underneath it, you’re almost always working with a square (or fixed-aspect) rectangle; the circle is just how it’s presented visually, usually with&amp;nbsp;&lt;code&gt;border-radius: 50%&lt;/code&gt;&amp;nbsp;or an SVG clip-path.&lt;/p&gt;

&lt;p&gt;The important part is what you store. Don’t save a pre-cropped, pre-masked image and call it done. Save the crop rectangle: x, y offset, width, height, maybe a zoom factor, as data. That rectangle is what lets you regenerate the avatar at any size later, or re-render it somewhere else in your app without asking the user to crop again.&lt;/p&gt;

&lt;p&gt;Here’s a simplified example of turning crop state into a transformation URL:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function buildAvatarUrl(baseUrl, crop) {
const { x, y, width, height } = crop;
// crop: pixel rectangle from the user's selection
const cropParam = `crop=x:${x},y:${y},w:${width},h:${height}`;
const resizeParam = `resize=width:400,height:400`;
const circleParam = `circle`;
return `${baseUrl}/${cropParam}/${resizeParam}/${circleParam}`;
}
// Usage
const avatarUrl = buildAvatarUrl(
'&amp;lt;https://cdn.filestackcontent.com/HANDLE&amp;gt;',
{ x: 120, y: 40, width: 300, height: 300 }
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Pinch-to-zoom on mobile and scroll-to-zoom on desktop both just adjust the crop rectangle’s dimensions before you apply the aspect lock. The masking (circle, rounded corners, whatever your design calls for) stays a purely visual layer on top.&lt;/p&gt;

&lt;p&gt;Once the crop rectangle exists as data, the natural next question is how to wire all of this into your actual app, which is where framework choice starts to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  React Implementation Notes
&lt;/h2&gt;

&lt;p&gt;If you’re building this in React, you’ve got two general paths: assemble it from smaller libraries, or use a composed upload component that already handles picking, preview, and cropping together.&lt;/p&gt;

&lt;p&gt;The DIY route usually means pairing a drag-and-drop hook (like&amp;nbsp;&lt;code&gt;react-dropzone&lt;/code&gt;) with a cropping library (like&amp;nbsp;&lt;code&gt;react-easy-crop&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;react-image-crop&lt;/code&gt;) and writing your own state management to connect them: file selection updates preview state, crop interactions update crop state, and a submit handler stitches it all into an upload request.&lt;/p&gt;

&lt;p&gt;The composed route hands you a single component that already wires selection, preview, and crop together, and hands back a crop rectangle or transform URL through callbacks. This tends to save the most time on the parts that are easy to get subtly wrong: touch gestures, aspect-ratio locking, and keeping crop state in sync with the preview across re-renders.&lt;/p&gt;

&lt;p&gt;Either way, the core pattern from the sections above doesn’t change: local preview first, crop rectangle as the source of truth, transform applied consistently. React just gives you hooks and component boundaries to organise it in.&lt;/p&gt;

&lt;p&gt;With the crop rectangle in hand, the next piece is making sure it doesn’t just produce one image; it needs to produce every size your app actually uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Renditions and Delivery
&lt;/h2&gt;

&lt;p&gt;Avatars rarely need just one size. A profile page might want a large version, a comment thread wants something small, a notification badge wants smaller still. Generating and storing every variant at upload time is wasteful, and worse, if you ever change your sizing needs, you’re stuck regenerating old uploads.&lt;/p&gt;

&lt;p&gt;A cleaner pattern is to store one master image (a common target is 400×400) and generate renditions on request using resize parameters in the URL, cached at the CDN layer so repeat requests don’t reprocess the image.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwkqzd6pm4a3wb32wbsm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwkqzd6pm4a3wb32wbsm.png" alt=" " width="720" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This also keeps your crop rectangle useful. Since the master retains the full crop, you can request a 128px rendition for a profile header and a 32px one for a notification badge, and both come from the same source of truth; you don’t need a separate crop step per size.&lt;/p&gt;

&lt;p&gt;Storing renditions this way is also what makes it practical to add new sizes later without touching old data. That flexibility becomes even more useful once you look at how the whole flow — picker, crop, and delivery — can share one implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Managed Route: Preview Equals Result
&lt;/h2&gt;

&lt;p&gt;Everything covered so far: instant preview, crop rectangle as data, consistent transforms, on-demand renditions, can be built by hand. It’s also, unsurprisingly, the exact shape of the problem a managed&lt;a href="https://www.filestack.com/products/file-upload/" rel="noopener noreferrer"&gt;&amp;nbsp;upload ui&lt;/a&gt;&amp;nbsp;is built to solve: picker, crop interface, and transform URLs sharing the same underlying handle and parameters, so the crop a user approves in the picker is the same crop that renders in production.&lt;/p&gt;

&lt;p&gt;This isn’t just about making things easier. It also helps prevent crop mismatch bugs. When the crop tool produces the same transformation used to display the final image, you don’t have to calculate the crop twice. That means there’s less chance of the preview and the uploaded image getting out of sync.&lt;/p&gt;

&lt;p&gt;If you’re evaluating this route, it’s worth looking at how the&lt;a href="https://www.filestack.com/docs/api/processing/#crop" rel="noopener noreferrer"&gt;&amp;nbsp;picker’s crop options&lt;/a&gt;&amp;nbsp;are configured and how&lt;a href="https://www.filestack.com/docs/api/processing/#image-transformations" rel="noopener noreferrer"&gt;&amp;nbsp;image transformations&lt;/a&gt;&amp;nbsp;apply as URL parameters, the same pattern from the code snippet earlier in this article, just handled for you.&lt;/p&gt;

&lt;p&gt;For a closer look at resizing specifically, this piece on&lt;a href="https://blog.filestack.com/how-to-automatically-resize-fit-and-align-any-image-using-only-url-parameters/" rel="noopener noreferrer"&gt;&amp;nbsp;resizing images with URL parameters&lt;/a&gt;&amp;nbsp;is a good companion read.&lt;/p&gt;

&lt;p&gt;Whether you build this by hand or lean on a managed picker, the underlying principle stays the same, which is worth restating clearly before wrapping up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: One Source of Truth for the Crop
&lt;/h2&gt;

&lt;p&gt;A profile picture upload UI doesn’t need to be complicated, but it does need to be consistent. Show the preview instantly. Store the crop as a rectangle, not a rendered image. Apply that same rectangle everywhere the avatar shows up. Keep users informed with real states instead of a single generic spinner.&lt;/p&gt;

&lt;p&gt;Get those four things right, and the mismatch bug, the one where the saved avatar doesn’t match what the user approved, simply can’t happen, because there’s only one crop, used everywhere. At Filestack, this is the exact problem our upload and transformation tools are built around, and if you’re setting up this flow, it’s worth testing your crop logic against a sandbox account before committing to a full build.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What size should profile pictures be stored at?
&lt;/h2&gt;

&lt;p&gt;A 400×400 master is a common baseline, with smaller renditions (128px, 32px, etc.) generated on delivery as needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my avatar crop differently after saving?
&lt;/h2&gt;

&lt;p&gt;This usually means the preview and the server are running different crop math. Sharing one crop rectangle and one transform URL between them fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast should the preview appear?
&lt;/h2&gt;

&lt;p&gt;Under 100ms, using a local object URL, before any upload has started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;This article was published on the&lt;/em&gt;&lt;/strong&gt; &lt;a href="https://blog.filestack.com/profile-picture-upload-ui/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
    </item>
    <item>
      <title>Upload Contract Form UI Design for Signatures and Documents</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Fri, 21 Aug 2026 10:39:27 +0000</pubDate>
      <link>https://dev.to/ideradevtools/upload-contract-form-ui-design-for-signatures-and-documents-5590</link>
      <guid>https://dev.to/ideradevtools/upload-contract-form-ui-design-for-signatures-and-documents-5590</guid>
      <description>&lt;p&gt;Most contract forms don’t lose people at the signature. They lose them one step earlier, at the upload, when someone picks the wrong file, gets no feedback, and quietly gives up.&lt;/p&gt;

&lt;p&gt;Upload contract form UI design covers the interface patterns for collecting signed agreements: a document upload step with clear format guidance, inline preview, validation, a signature capture step, and explicit status feedback at every stage. Strong designs cut abandonment by showing per-step progress and by accepting camera captures on mobile, not just desktop file pickers.&lt;/p&gt;

&lt;p&gt;This piece walks through five patterns that hold up across real estate, HR, and fintech agreement flows. It includes two annotated interface examples and a short code snippet you can adapt. It also looks at where Filestack’s upload, preview, and OCR building blocks fit under these patterns. The patterns come first, the tooling second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Contract flows break at two points: the document upload and the signature. Each one needs its own visible progress state.&lt;/li&gt;
&lt;li&gt;Inline preview at upload time catches wrong-file mistakes before they turn into support tickets.&lt;/li&gt;
&lt;li&gt;OCR can read a contract as it comes in and prefill names and dates. Verification becomes a confirm, not a retype.&lt;/li&gt;
&lt;li&gt;Camera capture deserves the same design attention as file upload. Plenty of users are photographing paper, not exporting PDFs.&lt;/li&gt;
&lt;li&gt;Status honesty (uploaded, then scanned, then accepted) does more for trust than any amount of copywriting.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Anatomy of a Contract Upload Flow
&lt;/h2&gt;

&lt;p&gt;Break a contract upload flow into its parts, and you get five steps: intake guidance, upload, preview and verification, signature, and confirmation.&lt;/p&gt;

&lt;p&gt;Treat them as one blob, and you get one blob-sized failure, a form that just doesn’t work with no clue why.&lt;/p&gt;

&lt;p&gt;Treat them as five separate states, each with its own success and failure condition, and both debugging and designing get a lot easier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ofvygzf4ilayxqesflw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7ofvygzf4ilayxqesflw.png" alt=" " width="800" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you’re mapping this to actual interface pieces, think in components rather than steps: a drop zone, a file list, a preview pane, a progress indicator, a signature pad, and a confirmation banner. Each one maps to a step above, and each can be built, tested, and shipped on its own.&lt;/p&gt;

&lt;p&gt;This separation also makes it easier to talk about the flow with a team. “The upload step is failing” is vague. “Files are getting stuck between uploaded and scanned, and the UI never says why” is something an engineer can actually go fix. Naming the states first, before writing any code, usually surfaces exactly where a flow is thin.&lt;/p&gt;

&lt;p&gt;Once the anatomy is clear, the next question is what each piece actually needs to do well. Start with the step where most contract flows quietly lose people.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Document Step, Guidance and Preview
&lt;/h2&gt;

&lt;p&gt;The document step fails silently more often than any other part of the flow. Someone uploads a&amp;nbsp;&lt;code&gt;.heic&lt;/code&gt;&amp;nbsp;photo from their phone, the form accepts it without complaint, and three days later a reviewer discovers it won’t open.&lt;/p&gt;

&lt;p&gt;State your accepted formats and size limit before anyone touches the upload button. Don’t bury it in a tooltip they’ll never hover over. Pair that with drag-and-drop plus a plain browse button. Forcing one interaction pattern excludes people who don’t know the other exists.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuoq4ano8o9pojipk4o2v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuoq4ano8o9pojipk4o2v.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The single highest-leverage addition here is inline preview. Once a file lands, render it. Show the actual PDF or image, not just a filename, so the person can confirm it’s the right document before they submit anything. This is also where rejection should happen: immediately, with a specific reason (“this file is a .docx, we need a PDF or image”), not after a full-page reload three steps later.&lt;/p&gt;

&lt;p&gt;It’s worth resisting the urge to over-restrict the drop zone too. A common mistake is accepting only PDF, on the assumption that “real” contracts are always exported as PDFs. In practice, a large share of uploads are phone photos of printed pages, or scans saved as JPG. Accept images alongside PDFs and say so plainly in the guidance copy. That alone avoids a whole category of “why won’t this work” support tickets.&lt;/p&gt;

&lt;p&gt;Getting the file in cleanly sets up the next problem: making sure what’s inside it actually matches what the form expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification, Reading the Contract for Them
&lt;/h2&gt;

&lt;p&gt;Manual verification usually means asking someone to retype their own name, a date they already wrote by hand, and a few clause references. That’s a tedious way to confirm something the document already states.&lt;/p&gt;

&lt;p&gt;OCR-driven prefill flips this: pull the party names, dates, and key fields directly from the uploaded contract and show them next to the preview for confirmation. The person’s job shifts from typing to checking, which is faster and produces fewer transcription errors on both ends.&lt;/p&gt;

&lt;p&gt;Keep the extracted fields editable, but make sure edits update the form record, not the underlying document. The uploaded contract stays the source of truth. The extracted fields are just a convenience layer on top of it, and users should be able to tell the difference at a glance.&lt;/p&gt;

&lt;p&gt;There’s one design decision worth being deliberate about here: how much to trust the extraction. OCR on a clean, typed PDF is close to reliable. OCR on a handwritten or photographed contract is not. Presenting low-confidence extractions with the same visual weight as high-confidence ones sets people up to accept a wrong date without noticing. A simple confidence indicator, or even just flagging fields pulled from an image instead of a text-based PDF, keeps verification meaningful instead of another box to click through.&lt;/p&gt;

&lt;p&gt;With the document verified, the flow moves into its second failure-prone stretch: actually collecting the signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Signature Step and Status Honesty
&lt;/h2&gt;

&lt;p&gt;Offer draw, type, and upload-a-saved-signature as three parallel options rather than forcing one method. Some people are on a trackpad, some are on mobile with a finger, and some already have a signature image saved from a previous form. None of these should be treated as the “real” method with the others bolted on as afterthoughts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevjojlvrclh6rje0zg4p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fevjojlvrclh6rje0zg4p.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The submit button should stay disabled until the document has actually cleared processing, meaning scanned, checked, accepted, not the moment a file appears in the list. That processing time is also worth surfacing as a positive signal instead of a silent spinner. A visible “scanning for security” state, even for a couple of seconds, reads as diligence rather than delay. It’s a small design choice that does real work for trust without needing any explanatory copy at all.&lt;/p&gt;

&lt;p&gt;Desktop and mobile signing look similar on paper, but mobile brings its own upload problem entirely. It’s easy to treat that as a lesser version of the desktop flow instead of a path in its own right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile, Photographing Paper
&lt;/h2&gt;

&lt;p&gt;A lot of contracts start on paper: a lease printed and signed in person, a form filled out by hand. Mobile camera capture is how that paper gets into the system. Treat this as a primary path, not a workaround bolted onto the file picker.&lt;/p&gt;

&lt;p&gt;That means edge guidance so the whole page is in frame, auto-crop once the edges are detected, and a glare warning if the flash is washing out part of the text.&lt;/p&gt;

&lt;p&gt;None of this is exotic, but it’s easy to skip if the design process starts from “upload a PDF” and treats the camera as an edge case. For a meaningful share of users, the camera is the primary case.&lt;/p&gt;

&lt;p&gt;The signature and verification patterns above still apply once a photo comes in. OCR just has to work a little harder against a slightly skewed, unevenly lit image instead of a clean digital export. That’s a good reason to invest in capture quality up front, good cropping, no glare, rather than compensating for it later with more aggressive text extraction.&lt;/p&gt;

&lt;p&gt;All five of these patterns lean on the same small set of underlying capabilities, which is worth naming plainly before wrapping up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Managed Route, Patterns to Production
&lt;/h2&gt;

&lt;p&gt;All five patterns above assemble from the same primitives: a production&lt;a href="https://www.filestack.com/products/file-upload/" rel="noopener noreferrer"&gt;&amp;nbsp;upload ui&lt;/a&gt;&amp;nbsp;for the document step, preview and OCR for verification, and status callbacks for honest progress. Building each of those from scratch (cross-browser drag-and-drop, PDF rendering in the browser, OCR pipelines, malware scanning) is a real project on its own, separate from designing the flow around them.&lt;/p&gt;

&lt;p&gt;Here’s a minimal example of wiring a picker to accept PDFs and images only, render a preview, and report per-file status back to your UI:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import * as filestack from "filestack-js";
const client = filestack.init("YOUR_API_KEY");
client.picker({
accept: ["application/pdf", "image/*"],
maxSize: 10 * 1024 * 1024, // 10MB
onFileUploadStarted: (file) =&amp;gt; updateStatus(file, "uploading"),
onFileUploadFinished: (file) =&amp;gt; updateStatus(file, "uploaded"),
onFileUploadFailed: (file, error) =&amp;gt; updateStatus(file, "failed", error),
}).open();
function updateStatus(file, status, error) {
// Drive the honest status pill from real events, not a timer
console.log(file.filename, status, error || "");
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Twelve lines get you accept-type filtering, size limits, and the event hooks a status indicator needs.&lt;/p&gt;

&lt;p&gt;From there, preview and OCR calls attach to the same uploaded file reference. If you’re a product manager on a real estate team trying to get a working uploader in front of users this sprint instead of next quarter, this is usually the fastest path there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Design for the Wrong File
&lt;/h2&gt;

&lt;p&gt;Good upload contract form UI design isn’t really about the happy path. It’s about what happens when someone picks the wrong file, photographs a blurry page, or leaves the tab open mid-signature. Show format guidance before the mistake happens. Preview the actual file, always. Let OCR turn verification into a confirmation instead of a retype. Report status honestly at every step. Treat the camera as a first-class input, not a fallback.&lt;/p&gt;

&lt;p&gt;If you’re prototyping this yourself, the&lt;a href="https://www.filestack.com/" rel="noopener noreferrer"&gt;&amp;nbsp;Filestack&lt;/a&gt;&amp;nbsp;picker sandbox is a fast way to test the document step, upload, preview, and status callbacks before you commit to a full build.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What steps should a contract upload form have?
&lt;/h2&gt;

&lt;p&gt;Guidance, upload, preview and verification, signature, and confirmation, each with its own visible state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should users see the contract after uploading?
&lt;/h2&gt;

&lt;p&gt;Yes. Inline preview before submission is the single most effective way to prevent wrong-file errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can data be extracted from uploaded contracts?
&lt;/h2&gt;

&lt;p&gt;Yes. OCR can prefill names, dates, and other fields directly from the document for the user to confirm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Originally published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/upload-contract-form-ui-design/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>file</category>
      <category>upload</category>
    </item>
    <item>
      <title>When Uploading Many Small Files Becomes a Denial of Service Risk</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Wed, 19 Aug 2026 11:49:44 +0000</pubDate>
      <link>https://dev.to/ideradevtools/when-uploading-many-small-files-becomes-a-denial-of-service-risk-3bl4</link>
      <guid>https://dev.to/ideradevtools/when-uploading-many-small-files-becomes-a-denial-of-service-risk-3bl4</guid>
      <description>&lt;p&gt;Monday morning, and your API is pinned at 100% CPU. Nobody uploaded anything huge. There’s no 4K video sitting in the queue, no multi-gigabyte archive. Just 60,000 tiny files, a handful from enthusiastic customers batch-uploading thumbnails, and a few thousand more from a script that’s testing how far your endpoint bends.&lt;/p&gt;

&lt;p&gt;Uploading many small files denial of service scenarios rarely start as attacks. They usually start as a power user with a folder of 4,000 icons, or an integration partner that decided to sync every asset it owns in one go. The danger isn’t the bytes. It’s everything your server does&amp;nbsp;&lt;em&gt;per file&lt;/em&gt;, multiplied by a number that got out of hand.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Uploading many small files becomes a denial of service risk when per-file overhead, connections, auth checks, disk metadata writes, and scan jobs multiply faster than payload size. A thousand 10KB files can cost more than one 10MB file. Defences include batch limits, rate limiting, queued ingestion, and offloading uploads to a managed pipeline such as Filestack that absorbs the fan-out before it reaches your servers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article walks through why small files hit harder than their size suggests, where the attack surface actually lives, and the layered defences: limits, rate shaping, queues, and asynchronous scanning, that keep an upload endpoint standing under pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Per-file fixed costs (auth checks, DB writes, storage PUTs, scan jobs), not raw byte count, are usually what breaks an upload endpoint first.&lt;/li&gt;
&lt;li&gt;A thousand 10KB files can cost your infrastructure more than a single 10MB file, because every file drags its own overhead along with it.&lt;/li&gt;
&lt;li&gt;Archive uploads need expansion-ratio caps; an unzipped “small” file can balloon into gigabytes of decompression work.&lt;/li&gt;
&lt;li&gt;Batch limits, per-account rate limiting, and queued ingestion are the first line of defence and don’t require rearchitecting your stack.&lt;/li&gt;
&lt;li&gt;Moving ingestion to a managed file uploader relocates the fan-out entirely, so your API sees metadata events instead of raw byte streams.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s start with the part that trips most teams up: why small files are, counterintuitively, the more expensive problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Small Files Hurt More Than Big Ones
&lt;/h2&gt;

&lt;p&gt;If you’re wondering what are the best methods to upload multiple files at once in a web application, the honest answer starts with a warning: multi-file upload is where fixed-cost overhead becomes visible for the first time. A single request has one TLS handshake, one auth check, one database write, one storage call. A thousand-file batch has a thousand of each, even if the total payload is identical.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yltvofgsp0andk8bgrn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yltvofgsp0andk8bgrn.png" alt=" " width="800" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The crossover point is easy to miss because it isn’t about total size at all. Object storage providers bill PUT requests per operation, separately from bytes stored, so a million tiny files can genuinely cost more in request fees than in storage. The same logic applies to your own compute: a database write or a virus-scan job doesn’t get cheaper because the file behind it is small. Once you see the pattern, it’s clear that file&amp;nbsp;&lt;em&gt;count&lt;/em&gt;&amp;nbsp;deserves the same scrutiny as file&amp;nbsp;&lt;em&gt;size&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Small files aren’t the whole story, though. The same overhead pattern shows up in more deliberate ways once you start thinking about upload endpoints as attack surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Attack Surface, Fan-Out and Amplification
&lt;/h2&gt;

&lt;p&gt;Ask how can I prevent file upload vulnerabilities in my web application, and per-file overhead is only half the answer; the other half is amplification. A handful of small, cheap uploads can trigger disproportionately expensive work downstream.&lt;/p&gt;

&lt;p&gt;Archive uploads are the clearest example. A 2MB zip file looks harmless at the network layer, but if it decompresses into 4GB of nested files, every downstream step: storage, scanning, indexing, inherits that expansion. Left unchecked, this is the classic zip-bomb pattern: a small input engineered to produce enormous output. Deep archive trees (folders inside folders inside folders) create a similar problem for anything that walks the file structure recursively.&lt;/p&gt;

&lt;p&gt;Scan-job amplification follows the same shape. If every uploaded file queues a virus scan synchronously, a burst of a few thousand small files can back up your scanning workers even though none of them are individually suspicious. And metadata-write storms, a database row or search-index update per file, can degrade a shared database well before storage or bandwidth becomes the bottleneck.&lt;/p&gt;

&lt;p&gt;If you’re also asking how do I detect and block malicious files during upload, the practical answer is to combine content-type verification, archive expansion limits, and asynchronous scanning (more on that in a moment) rather than relying on any single check. It’s worth noting that the line between “abuse” and “legitimate burst” is often blurry; a real customer syncing a large media library looks a lot like an attack until you’ve built the throttles that treat both cases the same way.&lt;/p&gt;

&lt;p&gt;That overlap is actually good news operationally: the same defences that stop an attacker also stop a well-meaning customer from accidentally taking your API down. Here’s what that layered defence looks like in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defences, Limits, Rate Shaping and Queues
&lt;/h2&gt;

&lt;p&gt;The first layer is the simplest: batch caps and per-account rate limits, enforced before a request does any real work. A sensible&lt;a href="https://www.filestack.com/docs/uploads/pickers/" rel="noopener noreferrer"&gt;&amp;nbsp;file-count&lt;/a&gt;&amp;nbsp;cap per batch (say, 100 files per request) turns an unbounded upload into a predictable, budgetable unit of work. Layer a token-bucket rate limiter per account on top, and a single client, malicious or just enthusiastic, can’t monopolise your ingestion capacity.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you’re using Filestack’s Picker, you can enforce upload limits with options such as&lt;/em&gt;&amp;nbsp;&lt;code&gt;maxFiles&lt;/code&gt;*,*&amp;nbsp;&lt;code&gt;minFiles&lt;/code&gt;*, and related*&lt;a href="https://filestack.github.io/filestack-js/interfaces/PickerOptions.html" rel="noopener noreferrer"&gt;&amp;nbsp;&lt;em&gt;file-count controls&lt;/em&gt;&lt;/a&gt;&amp;nbsp;&lt;em&gt;before the upload even begins.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The second layer is queued ingestion. Instead of processing every file synchronously inside the request/response cycle, accept the upload, write a lightweight acknowledgement, and let a queue smear the actual processing over time. This is the difference between a burst that spikes your CPU for ten seconds and a burst that quietly drains over ten minutes without anyone noticing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvvy1go0x705dy8hdvf3y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvvy1go0x705dy8hdvf3y.png" alt=" " width="799" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Speed expectations matter here too. If you’re comparing which platforms support the fastest bulk uploader options, the honest tradeoff is that raw speed and abuse-resistance pull in opposite directions; a platform optimised purely for throughput without rate shaping is also the one most exposed to fan-out abuse. The platforms that hold up under both legitimate bulk traffic and adversarial bursts are the ones that queue by design, not the ones that simply accept everything as fast as possible.&lt;/p&gt;

&lt;p&gt;Limits and queues buy you time and predictability. What you do with that time, specifically, how you scan what’s coming in, is the next piece.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scanning Without Melting
&lt;/h2&gt;

&lt;p&gt;If you’re figuring out how can I add virus scanning to file uploads in my application, the short version is: never run it synchronously on the request path. A scan that blocks the upload response until it completes means your scanning capacity&amp;nbsp;&lt;em&gt;is&lt;/em&gt;&amp;nbsp;your upload capacity, and a burst of files instantly becomes a burst of blocked requests.&lt;/p&gt;

&lt;p&gt;The more resilient pattern is quarantine-then-release: accept the file, store it in a location that isn’t yet accessible to end users, queue an asynchronous scan job, and only promote the file to “available” once the scan clears. This decouples upload throughput from scan throughput entirely, so a scanning backlog degrades gracefully (files take longer to become available) instead of catastrophically (uploads start failing).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you’re implementing malware detection or security policies,&lt;/em&gt;&lt;a href="https://www.filestack.com/docs/security/policies/" rel="noopener noreferrer"&gt;&amp;nbsp;&lt;em&gt;Filestack’s Security documentation&lt;/em&gt;&lt;/a&gt;&amp;nbsp;&lt;em&gt;covers built-in virus scanning, content validation, and upload security features in more detail.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Expansion-ratio caps belong here too. If an archive’s uncompressed size exceeds some multiple of its compressed size, say, 100x, reject or flag it before extraction runs to completion. It’s a small check that closes off the zip-bomb path discussed earlier, and it costs almost nothing to enforce.&lt;/p&gt;

&lt;p&gt;Building and maintaining all of this — batch caps, rate limiters, queues, asynchronous scanning, expansion checks, is real infrastructure work. It’s worth being clear-eyed about what it takes to run in-house before deciding whether to build it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Managed Route, Move the Blast Radius
&lt;/h2&gt;

&lt;p&gt;Everything above reduces the damage a flood of small files can do once it hits your infrastructure. The strongest structural defence is not absorbing the fan-out at all: a managed&lt;a href="https://www.filestack.com/products/file-upload/" rel="noopener noreferrer"&gt;&amp;nbsp;file uploader&lt;/a&gt;&amp;nbsp;terminates the file traffic upstream and hands your API a stream of metadata events instead of raw byte streams.&lt;/p&gt;

&lt;p&gt;Filestack’s upload pipeline runs on infrastructure built to absorb this kind of fan-out; file count and rate limits are enforced in the picker before the network is even touched; virus detection runs inline in the pipeline rather than queuing on your own workers, and your servers only receive webhooks once a file is safely ingested and checked. Your application never has to reason about 1,000 simultaneous PUT requests, because it never sees them.&lt;/p&gt;

&lt;p&gt;For an IT director at a startup company asking what’s the most secure way to manage hundreds of file uploads, the calculus is straightforward: every control described in this article: caps, throttles, queues, scanning, has to be built, tuned, and maintained somewhere. Relocating that surface to a system designed for it is less about outsourcing effort and more about outsourcing blast radius.&lt;/p&gt;

&lt;p&gt;Whichever direction you take, build it in-house or hand off the fan-out, the underlying principle doesn’t change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Count Files, Not Just Bytes
&lt;/h2&gt;

&lt;p&gt;The instinct to watch for “big” uploads is understandable, but it misses where most upload endpoints actually break. Cap file counts per batch, shape request rates per account, queue ingestion so bursts smear over time, and scan asynchronously so a backlog degrades instead of cascading. Where the fan-out is large or unpredictable enough, relocating ingestion to a managed pipeline like Filestack removes the problem from your infrastructure entirely.&lt;/p&gt;

&lt;p&gt;If you haven’t audited your own upload endpoint against these checks, start with the layered defences in the “Defences, Limits, Rate Shaping and Queues” section above; file-count caps and rate limiting alone catch most of the risk with the least amount of new infrastructure. And if you’d rather not build and maintain that stack yourself, Filestack’s upload pipeline handles the caps, queueing, and scanning for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  How can small files cause a denial of service?
&lt;/h2&gt;

&lt;p&gt;Per-file fixed costs: auth checks, database writes, storage requests, scan jobs, multiply with every file added to a batch. Thousands of tiny files can out-cost a single large file even though the total bytes transferred are far smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What limits should an upload endpoint enforce?
&lt;/h2&gt;

&lt;p&gt;At minimum: file-count caps per batch, per-account rate limits, and archive expansion-ratio caps to prevent zip-bomb-style decompression attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a managed uploader help?
&lt;/h2&gt;

&lt;p&gt;Yes. Filestack terminates upload traffic upstream, so your API receives metadata events rather than raw byte streams, removing the fan-out from your infrastructure entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;This article was published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/many-small-files-denial-of-service/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>file</category>
      <category>upload</category>
    </item>
    <item>
      <title>Your First 10 Minutes With Filestack, Signup to First Upload</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Thu, 13 Aug 2026 12:28:07 +0000</pubDate>
      <link>https://dev.to/ideradevtools/your-first-10-minutes-with-filestack-signup-to-first-upload-3nmp</link>
      <guid>https://dev.to/ideradevtools/your-first-10-minutes-with-filestack-signup-to-first-upload-3nmp</guid>
      <description>&lt;p&gt;Signup gives you an API key. The key gets you an upload. The upload gives you a handle, and the handle is the only thing you need for every transformation and delivery URL after that.&lt;/p&gt;

&lt;p&gt;Those four steps are the whole first session with Filestack, and each one takes minutes rather than hours. The walkthrough below runs all four and shows what each returned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get a key
&lt;/h2&gt;

&lt;p&gt;Sign up at&amp;nbsp;&lt;a href="https://www.filestack.com/signup-free/" rel="noopener noreferrer"&gt;filestack.com/signup-free&lt;/a&gt;. The form asks for a name, a company email and a password, and it shows the free plan allowance next to the fields you are filling in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25gmx5h4hp6c53lx2f0p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25gmx5h4hp6c53lx2f0p.png" alt=" " width="800" height="521"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your API key appears in the developer portal as soon as the account exists. It is about 20 characters, it identifies your application, and it is not a secret in the way a password is. It goes in client-side JavaScript on purpose, because that is how browser uploads reach us without a round trip through your server first.&lt;/p&gt;

&lt;p&gt;The app secret is different. It stays on your server, it signs security policies, and nothing in this walkthrough needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your first upload
&lt;/h2&gt;

&lt;p&gt;Two paths get a file in. Pick the one that matches where you are sitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uploading from a browser
&lt;/h2&gt;

&lt;p&gt;The picker is a hosted upload interface. Loading the script and calling&amp;nbsp;&lt;code&gt;picker()&lt;/code&gt;&amp;nbsp;is the shortest route to a working upload, and it handles the retry and chunking work that hand-rolled&amp;nbsp;&lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;&amp;nbsp;code usually skips.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;script src="https://static.filestackapi.com/filestack-js/3.x.x/filestack.min.js"&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;button id="pick"&amp;gt;Upload a file&amp;lt;/button&amp;gt;

&amp;lt;script&amp;gt;
  const client = filestack.init('YOUR_API_KEY');
  document.getElementById('pick').onclick = () =&amp;gt; {
    client.picker({
      accept: ['image/*'],
      maxFiles: 5,
      onUploadDone: ({ filesUploaded }) =&amp;gt; console.log(filesUploaded[0].handle),
    }).open();
  };
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Click the button and the picker opens over your page. My Device is the local file system. The icons down the left are the other sources the free plan includes, so a multi file upload UI with Google Drive and a URL tab costs you nothing beyond the&amp;nbsp;&lt;code&gt;fromSources&lt;/code&gt;&amp;nbsp;array.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38skeii0nijmwzyu7ssk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38skeii0nijmwzyu7ssk.png" alt=" " width="690" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Choose a file and it appears in a review list with its size before anything is sent. Nothing uploads until you press Upload, which is worth knowing when you are testing against a quota.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftsmabq2n1hrgmk3atif.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fftsmabq2n1hrgmk3atif.png" alt=" " width="690" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press it and&amp;nbsp;&lt;code&gt;onUploadDone&lt;/code&gt;&amp;nbsp;fires with one entry in&amp;nbsp;&lt;code&gt;filesUploaded&lt;/code&gt;. The&amp;nbsp;&lt;code&gt;handle&lt;/code&gt;&amp;nbsp;field on that entry is what every URL below uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Uploading without a browser
&lt;/h2&gt;

&lt;p&gt;If you are on a server or just want to see the response shape, one POST does it:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -X POST -F "fileUpload=@photo.jpg" \
  "https://www.filestackapi.com/api/store/S3?key=YOUR_API_KEY"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;which returns:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "url": "https://cdn.filestackcontent.com/0J0PpoBYScqJrHF8lbrO",
  "size": 107013,
  "type": "image/jpeg",
  "filename": "photo.jpg"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;That is the same REST API upload file endpoint the SDKs sit on top of, so the handle it returns behaves identically. The 20 characters at the end of that URL are the handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the handle is for
&lt;/h2&gt;

&lt;p&gt;The handle is the file. Every delivery and processing URL is the handle with tasks in front of it:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cdn.filestackcontent.com/TASK/HANDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F816ksyhzb7zkxr1k6qvy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F816ksyhzb7zkxr1k6qvy.png" alt=" " width="300" height="450"&gt;&lt;/a&gt;\&lt;/p&gt;

&lt;p&gt;Your API key does not go in that URL. The handle already identifies the application that owns the file, so adding the key puts a credential in front of your users for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your first transformation
&lt;/h2&gt;

&lt;p&gt;Put a task in front of the handle and the file changes on the way out. Resize is the one to try first, because the result is obvious:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cdn.filestackcontent.com/resize=width:300/HANDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The 107,013 byte original came back as 42,214 bytes at 300 pixels wide. Nothing was stored to produce that. The transformation ran at request time and the result was cached, which is why you never generate thumbnail variants ahead of time or keep them anywhere.&lt;/p&gt;

&lt;p&gt;Tasks chain left to right. Adding a format change on the end took the same request to 32,854 bytes:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cdn.filestackcontent.com/resize=width:300/output=format:webp/HANDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Order matters, because each task acts on what the previous one produced. Resize first and the encoder is working on a smaller image. The reasoning behind picking a format at all is in the guide to&amp;nbsp;&lt;a href="https://blog.filestack.com/complete-image-file-extension-list" rel="noopener noreferrer"&gt;convert to webp&lt;/a&gt;, and the full parameter set for every task is in the&amp;nbsp;&lt;a href="https://www.filestack.com/docs/api/processing" rel="noopener noreferrer"&gt;processing API reference&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Crop, rotate, watermark, compress and quality all work the same way on a free key, as does face detection, so you can&amp;nbsp;&lt;a href="https://blog.filestack.com/detect-blur-faces-nodejs-react" rel="noopener noreferrer"&gt;blur faces&lt;/a&gt;&amp;nbsp;in a URL without training anything. The&amp;nbsp;&lt;a href="https://blog.filestack.com/image-editing-api-crop-resize-watermark-convert" rel="noopener noreferrer"&gt;image editing api&lt;/a&gt;&amp;nbsp;guide covers how the tasks combine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the file lives now
&lt;/h2&gt;

&lt;p&gt;At the CDN, already, on a public URL. There is no publish step and no bucket to configure. The default response carries&amp;nbsp;&lt;code&gt;cache-control: public, max-age=2667950&lt;/code&gt;, so once an edge has served a transformation it keeps serving it without rerunning anything.&lt;/p&gt;

&lt;p&gt;Set your own expiry when you need a shorter one:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://cdn.filestackcontent.com/cache=expiry:3600/resize=width:300/HANDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The Filestack CDN then answers that URL with&amp;nbsp;&lt;code&gt;cache-control: public, max-age=3600&lt;/code&gt;. How the edges pick up files and how long they hold them is covered in&amp;nbsp;&lt;a href="https://blog.filestack.com/how-the-filestack-cdn-delivers-your-files-fast-across-the-globe" rel="noopener noreferrer"&gt;file delivery&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Public by default matters for the next thing you build. Anyone with the handle can read the file, which is right for a portfolio and wrong for invoices, and the fix is a signed policy rather than a different upload call. A secure file upload service is a configuration you turn on later, not a separate product.&lt;/p&gt;

&lt;h2&gt;
  
  
  When something comes back wrong
&lt;/h2&gt;

&lt;p&gt;Every failure here answers in plain text, so read the body rather than guessing from the status code.&lt;/p&gt;

&lt;p&gt;What you sentStatusWhat the body saysA handle that does not exist400&lt;code&gt;Bad Request&lt;/code&gt;A task name with a typo400&lt;code&gt;validation error: task not found: "resiz"&lt;/code&gt;A parameter name with a typo400&lt;code&gt;validation error: invalid parameter widht for resize task&lt;/code&gt;An operation your plan does not include403&lt;code&gt;You don't have permission to perform this task: ocr. Please check your access settings&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That last one is the boundary worth knowing early. Operations that read and interpret a file, such as optical character recognition, tagging, captioning and enhancement, run on the higher plans. Everything that changes a file’s shape, size or format runs on the free plan, which is most of what a first project needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to try next
&lt;/h2&gt;

&lt;p&gt;The quotas are 500 uploads, 1,000 transformations, 1 GB of bandwidth and 1 GB of storage a month, checked on the&amp;nbsp;&lt;a href="https://www.filestack.com/pricing" rel="noopener noreferrer"&gt;free plan&lt;/a&gt;&amp;nbsp;page on 6 August 2026. A prototype does not come near them.&lt;/p&gt;

&lt;p&gt;Three directions from here, depending on what you are building:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wire it into your framework.&lt;/strong&gt;&amp;nbsp;The same three lines work in a React file upload component or behind an ordinary HTML form, with&amp;nbsp;&lt;code&gt;import * as filestack from 'filestack-js'&lt;/code&gt;&amp;nbsp;instead of the script tag. In Next.js the component needs&amp;nbsp;&lt;code&gt;'use client'&lt;/code&gt;, because the picker needs a browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chain transformations.&lt;/strong&gt;&amp;nbsp;Crop, then resize, then encode, in one URL, is the pattern behind every responsive image you will serve. Order matters, because resizing first means the encoder has fewer pixels to work on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Take the whole lifecycle seriously.&lt;/strong&gt;&amp;nbsp;Once uploads are real user files, storage, transformation and delivery become one system. The&amp;nbsp;&lt;a href="https://blog.filestack.com/image-upload-service-store-transform-deliver-images" rel="noopener noreferrer"&gt;image upload service&lt;/a&gt;&amp;nbsp;guide covers how those pieces fit together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Originally published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/your-first-10-minutes-with-filestack-signup-to-first-upload/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
    </item>
    <item>
      <title>How to Handle Failed and Interrupted JavaScript File Uploads</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:22:39 +0000</pubDate>
      <link>https://dev.to/ideradevtools/how-to-handle-failed-and-interrupted-javascript-file-uploads-4pna</link>
      <guid>https://dev.to/ideradevtools/how-to-handle-failed-and-interrupted-javascript-file-uploads-4pna</guid>
      <description>&lt;p&gt;File uploads can fail even when your code is working correctly. Real-world networks aren’t always reliable. A phone can lose signal, hotel Wi-Fi can disconnect during an upload, or a user might close the tab before the upload finishes.&lt;/p&gt;

&lt;p&gt;If your file upload API isn’t prepared for these situations, users may see a loading spinner that never ends. This can quickly make them lose trust in your app.&lt;/p&gt;

&lt;p&gt;This guide assumes you already have a basic upload flow with features like drag-and-drop, a progress bar, and file validation. We won’t cover how to build those again.&lt;/p&gt;

&lt;p&gt;Instead, we’ll focus on what happens when an upload fails. You’ll learn how to detect failed uploads, retry them without causing more problems, and resume large uploads instead of starting again from the beginning.&lt;/p&gt;

&lt;p&gt;We’ll use simple, beginner-friendly JavaScript examples and also look at how&lt;a href="https://www.filestack.com/sdks/javascript/" rel="noopener noreferrer"&gt;&amp;nbsp;Filestack’s JavaScript SDK&lt;/a&gt;&amp;nbsp;handles some of this out of the box.&lt;/p&gt;

&lt;h1&gt;
  
  
  Key Takeaways
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;Most upload failures happen because of network issues, timeouts, or closed tabs, not because your code is wrong.&lt;/li&gt;
&lt;li&gt;Handle network and server errors differently instead of treating every error the same.&lt;/li&gt;
&lt;li&gt;Wait a little longer between each retry (exponential backoff), and limit how many times you retry.&lt;/li&gt;
&lt;li&gt;For large files, continue from the last uploaded chunk instead of starting from the beginning.&lt;/li&gt;
&lt;li&gt;Show clear error messages and keep the upload progress instead of resetting it to 0%. This helps users trust your app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before we look at how to handle failed uploads, here’s a quick refresher on why they happen in the first place.&lt;/p&gt;

&lt;h1&gt;
  
  
  Why Uploads Fail
&lt;/h1&gt;

&lt;p&gt;We won’t go through every reason an upload can fail here.&lt;a href="https://blog.filestack.com/why-most-file-uploads-fail-and-what-to-do-about-it/" rel="noopener noreferrer"&gt;&amp;nbsp;Why most file uploads fail and what to do about it&lt;/a&gt;&amp;nbsp;already explains common causes in detail, from filename issues and server timeouts to poor network connections. It’s also worth reading&lt;a href="https://blog.filestack.com/javascript-file-upload-api-expectations-vs-reality/" rel="noopener noreferrer"&gt;&amp;nbsp;JavaScript file upload API: expectations vs. reality&lt;/a&gt;&amp;nbsp;if you’ve only tested your upload flow on fast office Wi-Fi. Real users may have much slower or less reliable connections. Here, we’ll focus on what happens&amp;nbsp;&lt;em&gt;after&lt;/em&gt;&amp;nbsp;an upload fails: how to detect the failure and recover from it smoothly.&lt;/p&gt;

&lt;p&gt;Now, before you retry an upload, you first need to know what actually went wrong.&lt;/p&gt;

&lt;h1&gt;
  
  
  Detecting a Failed Upload Reliably
&lt;/h1&gt;

&lt;p&gt;Upload failures aren’t always easy to identify. Different types of errors can happen, and each one may need a different response.&lt;/p&gt;

&lt;h1&gt;
  
  
  Network Error vs. Server Error
&lt;/h1&gt;

&lt;p&gt;A network error usually means the request didn’t get a response because the connection was lost. A server error means the server did respond, but returned an error status such as 500 or 503.&lt;/p&gt;

&lt;p&gt;These errors shouldn’t always be handled the same way. A network error is usually worth retrying. But some server errors, such as a 400 caused by an invalid file type, won’t be fixed by retrying.&lt;/p&gt;

&lt;p&gt;You can learn more about these status codes in the&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Status" rel="noopener noreferrer"&gt;&amp;nbsp;MDN guide to HTTP status codes&lt;/a&gt;.&lt;/p&gt;

&lt;h1&gt;
  
  
  Timeouts
&lt;/h1&gt;

&lt;p&gt;Sometimes an upload doesn’t fail or succeed; it simply keeps waiting. To handle this, you can set a timeout yourself instead of waiting forever.&lt;/p&gt;

&lt;p&gt;Here’s a simple example using fetch and AbortController, as explained on&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortController" rel="noopener noreferrer"&gt;&amp;nbsp;MDN’s AbortController page&lt;/a&gt;:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function uploadWithTimeout(file, timeoutMs = 15000) {
  const controller = new AbortController();
  const timer = setTimeout(() =&amp;gt; controller.abort(), timeoutMs);

const formData = new FormData();
  formData.append("file", file);
  try {
    const response = await fetch("/api/upload", {
      method: "POST",
      body: formData,
      signal: controller.signal,
    });
    if (!response.ok) {
      // Server responded, but with an error status
      throw new Error(`Upload failed with status ${response.status}`);
    }
    return await response.json();
  } catch (err) {
    if (err.name === "AbortError") {
      throw new Error("Upload timed out. Please try again.");
    }
    throw err; // some other network or server error
  } finally {
    clearTimeout(timer);
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;This function helps you handle three common upload problems: a request that takes too long, a server that returns an error, and a network connection that drops.&lt;/p&gt;

&lt;p&gt;Once you know what type of failure happened, it’s much easier to decide whether you should retry the upload or handle the error in another way.&lt;/p&gt;

&lt;p&gt;Now that you can detect a failure, you need to decide when to retry it automatically and when to stop.&lt;/p&gt;

&lt;h1&gt;
  
  
  Retry Logic That Doesn’t Make Things Worse
&lt;/h1&gt;

&lt;p&gt;Retrying an upload sounds simple, but you need to do it carefully. Too many retries can send even more requests to a server that’s already having problems. In some cases, retries can also cause the same file to be uploaded twice.&lt;/p&gt;

&lt;h1&gt;
  
  
  Backoff, Not Immediate Retries
&lt;/h1&gt;

&lt;p&gt;If an upload fails, retrying it immediately may lead to the same error because the network or server hasn’t had time to recover.&lt;/p&gt;

&lt;p&gt;A better approach is&amp;nbsp;&lt;strong&gt;exponential backoff&lt;/strong&gt;. This means waiting a little longer after each failed attempt. For example, you might wait 1 second before the first retry, 2 seconds before the next, and then 4 seconds.&lt;/p&gt;

&lt;p&gt;This gives the network or server some time to recover. You can read more about why this approach is useful in&lt;a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/" rel="noopener noreferrer"&gt;&amp;nbsp;AWS’s write-up on exponential backoff and jitter&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0qxsxf5pjnm6nzm29mg0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0qxsxf5pjnm6nzm29mg0.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here’s a simple example:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function uploadWithRetry(file, maxAttempts = 4) {
  let attempt = 0;

while (attempt &amp;lt; maxAttempts) {
    try {
      return await uploadWithTimeout(file);
    } catch (err) {
      attempt++;
      if (attempt &amp;gt;= maxAttempts) {
        throw new Error("Upload failed after several attempts. Please try again later.");
      }
      const waitTime = 1000 * Math.pow(2, attempt - 1); // 1s, 2s, 4s...
      await new Promise((resolve) =&amp;gt; setTimeout(resolve, waitTime));
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;h1&gt;
  
  
  Set a Retry Limit
&lt;/h1&gt;

&lt;p&gt;Usually, three to five attempts are enough. If the upload still fails, stop retrying and show a clear error message with a button that lets the user try again manually. Don’t keep retrying silently in the background.&lt;/p&gt;
&lt;h1&gt;
  
  
  Avoid Duplicate Uploads
&lt;/h1&gt;

&lt;p&gt;Sometimes a request is slow but hasn’t actually failed. If you retry too soon, the same file could be uploaded twice.&lt;/p&gt;

&lt;p&gt;One way to prevent this is to track the state of each file using values such as pending, in-progress, succeeded, and failed. Before retrying, check the current state to make sure another upload isn’t already running.&lt;/p&gt;

&lt;p&gt;Some upload APIs also provide ways to identify duplicate requests, so the server can avoid processing the same upload more than once.&lt;/p&gt;

&lt;p&gt;Once your retry logic is in place, the next step is making sure large files don’t have to start over after every failed attempt.&lt;/p&gt;
&lt;h1&gt;
  
  
  Resuming Instead of Restarting: Chunked and Resumable Uploads
&lt;/h1&gt;

&lt;p&gt;Retrying a small file isn’t a big problem. But if a 2GB video fails near the end, starting the entire upload again can be frustrating.&lt;/p&gt;

&lt;p&gt;For large files, restarting from 0% every time the connection drops isn’t a good experience. A user might wait several minutes, lose their connection for a moment, and then have to start all over again.&lt;/p&gt;

&lt;p&gt;A better approach is to split the file into smaller&amp;nbsp;&lt;strong&gt;chunks&lt;/strong&gt;. These chunks can be uploaded one at a time or a few at the same time. You also keep track of which chunks have already uploaded successfully.&lt;/p&gt;

&lt;p&gt;If the connection drops, you only need to upload the remaining or failed chunks instead of uploading the entire file again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwz6s62lopk6fcg1sltd6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwz6s62lopk6fcg1sltd6.png" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here’s a basic example of how chunking works:&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function splitIntoChunks(file, chunkSize = 5 * 1024 * 1024) {
  const chunks = [];
  let start = 0;

while (start &amp;lt; file.size) {
    chunks.push(file.slice(start, start + chunkSize));
    start += chunkSize;
  }
  return chunks;
}
async function uploadChunks(file) {
  const chunks = splitIntoChunks(file);
  const status = chunks.map(() =&amp;gt; "pending");
  for (let i = 0; i &amp;lt; chunks.length; i++) {
    if (status[i] === "succeeded") continue; // already uploaded, skip on resume
    try {
      await uploadWithRetry(chunks[i]);
      status[i] = "succeeded";
    } catch (err) {
      status[i] = "failed";
    }
  }
  return status;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The important part here is the status array. It keeps track of which chunks were uploaded successfully and which ones failed.&lt;/p&gt;

&lt;p&gt;So, if the connection drops, you don’t have to start the entire upload again. You can skip the completed chunks and upload only the ones that are still missing or failed.&lt;/p&gt;

&lt;p&gt;If you don’t want to build and maintain this chunking system yourself, the Filestack upload API can handle this kind of upload process for you. And if you’re building your own upload endpoints,&lt;a href="https://blog.filestack.com/design-javascript-api/" rel="noopener noreferrer"&gt;&amp;nbsp;designing a JavaScript API&lt;/a&gt;&amp;nbsp;is a useful next read for learning how to create an API that supports chunked and resumable uploads.&lt;/p&gt;

&lt;p&gt;Even if your retry logic works in the background, users still need to understand what’s happening.&lt;/p&gt;

&lt;h1&gt;
  
  
  Giving Users Useful Feedback Mid-Failure
&lt;/h1&gt;

&lt;p&gt;Two simple things can make a big difference:&lt;/p&gt;

&lt;h1&gt;
  
  
  Don’t Reset Progress to 0%
&lt;/h1&gt;

&lt;p&gt;If chunk 4 fails but chunks 1 through 3 were already uploaded successfully, don’t move the progress bar back to 0%. Keep the progress based on the chunks that have already finished.&lt;/p&gt;

&lt;h1&gt;
  
  
  Show Clear and Specific Errors
&lt;/h1&gt;

&lt;p&gt;A message like “Upload failed” doesn’t tell the user what went wrong or what they should do next. Instead, use messages like “Upload failed, check your connection and try again” or “This file is too large (max 500MB).” This helps users understand the problem and what they can do about it.&lt;/p&gt;

&lt;p&gt;If you’re uploading multiple files at the same time, tracking the status of each file becomes even more important.&lt;a href="https://blog.filestack.com/upload-multiple-files-using-javascript/" rel="noopener noreferrer"&gt;&amp;nbsp;Uploading multiple files using JavaScript&lt;/a&gt;&amp;nbsp;explains how you can use the same approach for multiple files, including tracking retries for each file separately.&lt;/p&gt;

&lt;p&gt;Once users can clearly see what’s happening during a failed upload, you also need to make sure these failure states work as expected before your app goes live.&lt;/p&gt;

&lt;h1&gt;
  
  
  Testing Failure Scenarios Before They Happen in Production
&lt;/h1&gt;

&lt;p&gt;It’s better to find upload problems during testing than when a real user runs into them.&lt;/p&gt;

&lt;p&gt;You don’t need an unreliable internet connection to test these situations. Your browser’s developer tools can simulate them for you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throttle the connection.&lt;/strong&gt;&amp;nbsp;Chrome and Firefox DevTools have network throttling options such as “Slow 3G” in the Network tab. Use them to see how your retry and backoff logic works on a slow connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulate an offline drop.&lt;/strong&gt;&amp;nbsp;DevTools also has an “Offline” option. Start uploading a file, switch to offline mode during the upload, and see how your app handles the failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test what happens when the tab closes.&lt;/strong&gt;&amp;nbsp;Start uploading a large file and close the tab before it finishes. When you open the app again, check whether it remembers which chunks were already uploaded or starts the whole upload again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frruqriuor45qxqcz73bh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frruqriuor45qxqcz73bh.png" alt=" " width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Testing these situations helps you find problems with retries, error handling, and resumable uploads before your users experience them.&lt;/p&gt;

&lt;p&gt;If you haven’t built the basic upload flow yet, the&lt;a href="https://blog.filestack.com/step-by-step-guide-to-html-file-upload-using-javascript/" rel="noopener noreferrer"&gt;&amp;nbsp;step-by-step guide to HTML file upload using JavaScript&lt;/a&gt;&amp;nbsp;is a good place to start before adding this failure-handling logic.&lt;/p&gt;

&lt;p&gt;Getting the file onto the server is the final step for this article, but it’s often just one part of the complete file workflow.&lt;/p&gt;

&lt;h1&gt;
  
  
  What Happens Downstream Once an Upload Finally Succeeds
&lt;/h1&gt;

&lt;p&gt;Once a file uploads successfully, most apps need to do something with it. For example, you might resize an image, convert a video to another format, or create a thumbnail.&lt;/p&gt;

&lt;p&gt;If you’re working with images,&lt;a href="https://blog.filestack.com/simplify-image-editing-javascript-sdk-web-app/" rel="noopener noreferrer"&gt;&amp;nbsp;simplifying image editing with a JavaScript SDK&lt;/a&gt;&amp;nbsp;explains some common ways to transform uploaded images.&lt;/p&gt;

&lt;p&gt;After processing, the file usually needs to be available to your app or users. This is often done using a CDN URL or signed URL, which lets the file be displayed, downloaded, or shared without uploading it again.&lt;/p&gt;

&lt;p&gt;With the full upload and recovery flow covered, here are a few best practices to keep in mind while putting everything together.&lt;/p&gt;

&lt;h1&gt;
  
  
  Best Practices and Common Pitfalls
&lt;/h1&gt;

&lt;p&gt;Here are a few important things to remember when handling failed uploads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do&lt;/strong&gt;&amp;nbsp;handle network errors and server errors differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do&lt;/strong&gt;&amp;nbsp;use exponential backoff and stop retrying after 3–5 attempts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do&lt;/strong&gt;&amp;nbsp;track the upload status of each file or chunk to prevent duplicate uploads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do&lt;/strong&gt;&amp;nbsp;keep the progress bar based on how much of the file has actually uploaded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t&lt;/strong&gt;&amp;nbsp;keep retrying in the background forever. Show an error message and give users a manual retry option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t&lt;/strong&gt;&amp;nbsp;retry every server error. For example, a 400 error caused by an invalid file type won’t be fixed by trying again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don’t&lt;/strong&gt;&amp;nbsp;restart large uploads from the beginning after a connection drop. Resume from the last successful chunk instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these practices in place, your upload flow will be much better prepared for the network problems users face in the real world.&lt;/p&gt;

&lt;h1&gt;
  
  
  Conclusion
&lt;/h1&gt;

&lt;p&gt;A file upload API shouldn’t only work when the internet connection is fast and stable. It should also be ready for slow networks, connection drops, and other common problems.&lt;/p&gt;

&lt;p&gt;You don’t need to rebuild your entire upload system to handle these issues. Focus on four things: detect failures correctly, retry failed uploads with increasing wait times and a clear limit, split large files into chunks so they can resume after a connection drop, and keep users informed about what’s happening.&lt;/p&gt;

&lt;p&gt;Getting these things right makes your upload experience much more reliable and user-friendly.&lt;/p&gt;

&lt;p&gt;If you don’t want to build and maintain all of this yourself,&lt;a href="https://www.filestack.com/sdks/javascript/" rel="noopener noreferrer"&gt;&amp;nbsp;Filestack’s JavaScript SDK&lt;/a&gt;&amp;nbsp;handles retries, chunking, and resumable uploads as part of its upload API.&lt;/p&gt;

&lt;h1&gt;
  
  
  FAQ
&lt;/h1&gt;

&lt;h1&gt;
  
  
  Why does a file upload fail even when the code has no bugs?
&lt;/h1&gt;

&lt;p&gt;Most upload failures happen because of real-world conditions, not problems in your code. A network connection might drop during an upload, especially on mobile; the server might time out while handling a large file, or the user might close the tab before the upload finishes.&lt;/p&gt;

&lt;h1&gt;
  
  
  How do I tell the difference between a network error and a server error in JavaScript?
&lt;/h1&gt;

&lt;p&gt;A network error usually means the request didn’t get a response, often because the connection was lost. A server error means the server responded with an error status code. Handle them differently: network errors are usually worth retrying, while some server errors are not.&lt;/p&gt;

&lt;h1&gt;
  
  
  Should I retry a failed upload immediately or wait?
&lt;/h1&gt;

&lt;p&gt;Instead of retrying immediately, wait a little and increase the wait time after each failed attempt. This is called exponential backoff. Retrying too quickly on a poor connection or overloaded server will often just fail again.&lt;/p&gt;

&lt;h1&gt;
  
  
  How do I avoid uploading the same file twice if a retry fires after a slow original request succeeds?
&lt;/h1&gt;

&lt;p&gt;Track the status of each file, such as in-progress, succeeded, or failed, and check it before retrying. You can also use an idempotency key to help the server identify and ignore duplicate requests.&lt;/p&gt;

&lt;h1&gt;
  
  
  Do I need chunked uploads to handle large file failures well?
&lt;/h1&gt;

&lt;p&gt;Not always, but it’s very useful for large files. It lets the upload continue from where the connection dropped instead of starting again from 0%, which is especially helpful on slow or unreliable networks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;This article was published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/javascript-file-upload-error-handling/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>fileupload</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Benchmark OCR API Accuracy Before Choosing a Vendor</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Fri, 07 Aug 2026 10:05:39 +0000</pubDate>
      <link>https://dev.to/ideradevtools/how-to-benchmark-ocr-api-accuracy-before-choosing-a-vendor-dph</link>
      <guid>https://dev.to/ideradevtools/how-to-benchmark-ocr-api-accuracy-before-choosing-a-vendor-dph</guid>
      <description>&lt;p&gt;Most teams testing an OCR API usually do one of two things: trust the accuracy number provided by the vendor or test a few random documents and make a decision based on the results.&lt;/p&gt;

&lt;p&gt;But neither approach gives you a reliable accuracy number. If certain types of documents start failing in production, it can be difficult to understand why.&lt;/p&gt;

&lt;p&gt;In this guide, we’ll look at a simple and repeatable way to test OCR accuracy using your own documents before choosing an OCR provider. You’ll learn how to create a realistic test set, prepare the correct results to compare against, run the benchmark, and understand the results.&lt;/p&gt;

&lt;p&gt;If you want to understand what affects OCR accuracy, such as image quality, lighting, and contrast,&amp;nbsp;&lt;a href="https://blog.filestack.com/improve-data-accuracy-ocr/" rel="noopener noreferrer"&gt;Improve Data Accuracy with OCR&lt;/a&gt;&amp;nbsp;covers those factors in more detail.&lt;/p&gt;

&lt;p&gt;Here, we’ll focus on how to measure and verify OCR accuracy. If you want to try this process with an OCR API,&amp;nbsp;&lt;a href="https://www.filestack.com/products/filestack-capture/" rel="noopener noreferrer"&gt;Filestack Capture, our OCR and data capture product&lt;/a&gt;, can be used to run a benchmark like this.&lt;/p&gt;

&lt;h1&gt;
  
  
  Key Takeaways
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Don’t rely on one overall accuracy percentage. Check OCR accuracy for each document type separately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use a smaller set of real, varied documents instead of testing only clean and perfect files.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Create a manually checked&amp;nbsp;&lt;strong&gt;ground truth&lt;/strong&gt;&amp;nbsp;to compare with the OCR results.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use confidence scores as an extra signal, but check whether they actually match real OCR errors.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Test OCR accuracy regularly because your documents and scanning methods can change over time.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let’s take a quick look at why the accuracy number provided by a vendor may not match what you see with your own documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Vendor-Quoted Accuracy Numbers Don’t Transfer to Your Documents
&lt;/h2&gt;

&lt;p&gt;A vendor might claim “99% accuracy,” but that number is usually based on its own test documents. Performance can be very different depending on the type and quality of the document. For example, an OCR tool might work very well on a clean, typed invoice but perform much worse on a blurry receipt photo or handwritten form.&lt;/p&gt;

&lt;p&gt;The documents used for vendor testing may also be cleaner and easier to read than the files your users actually upload. Real users might take photos with their phones in poor lighting, upload low-quality scans, or submit documents with different layouts.&lt;/p&gt;

&lt;p&gt;That’s why the best way to understand how an OCR API will perform for your use case is to test it with your own documents.&lt;/p&gt;

&lt;p&gt;Once you know that a vendor’s accuracy number is only a starting point, the next step is building a test that gives you results you can actually use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Representative Test Set
&lt;/h2&gt;

&lt;p&gt;Start by collecting real documents from each type your product handles. Don’t choose only clean, high-quality files. For example, if your product processes invoices, ID cards, and handwritten forms, include all three in your test set based roughly on how often you receive them.&lt;/p&gt;

&lt;p&gt;You also don’t need thousands of documents to get useful results.&amp;nbsp;&lt;strong&gt;Variety is more important than volume.&lt;/strong&gt;&amp;nbsp;For example, 100 documents that include tilted scans, poorly lit phone photos, low-contrast faxes, and clean files can be more useful than 1,000 documents that all look similar.&lt;/p&gt;

&lt;p&gt;Make sure you include difficult and messy documents. These are often where OCR tools struggle the most, and they may not be well represented in a vendor’s own tests.&lt;/p&gt;

&lt;p&gt;Once you have a representative set of documents, the next step is deciding what the correct OCR result should look like for each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Establishing Ground Truth
&lt;/h2&gt;

&lt;p&gt;Ground truth is a manually checked and correct version of the text in your test documents. Think of it as the answer key that you’ll compare the OCR results against.&lt;/p&gt;

&lt;p&gt;A person should carefully review and create the ground truth for at least a representative part of your test set. It takes more time, but it gives you a reliable way to know whether the OCR output is actually correct.&lt;/p&gt;

&lt;p&gt;Before running the benchmark, you also need to decide what counts as an error. There are two common ways to measure this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Character-level accuracy:&lt;/strong&gt;&amp;nbsp;Checks whether every letter, number, and character was recognised correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Field-level accuracy:&lt;/strong&gt;&amp;nbsp;Checks whether important fields, such as an invoice total, were extracted correctly, even if there are small formatting differences.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These methods can give you very different accuracy results for the same document. Choose how you’ll measure accuracy before you start and use the same method throughout your benchmark.&lt;/p&gt;

&lt;p&gt;This is similar to how speech recognition systems use&amp;nbsp;&lt;a href="https://en.wikipedia.org/wiki/Word_error_rate" rel="noopener noreferrer"&gt;word error rate&lt;/a&gt;&amp;nbsp;to measure errors consistently.&lt;/p&gt;

&lt;p&gt;Once you have your ground truth and know how you’ll measure accuracy, you’re ready to run the benchmark.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Benchmark
&lt;/h2&gt;

&lt;p&gt;When running your benchmark, don’t combine all the results into one overall accuracy score. Keep the results separate for each document type, such as invoices, receipts, forms, and handwritten notes.&lt;/p&gt;

&lt;p&gt;This makes it easier to see which document types the OCR API handles well and where it struggles.&lt;/p&gt;

&lt;p&gt;The testing process is simple: run each document through the OCR API and compare the returned text with your ground truth.&lt;/p&gt;

&lt;p&gt;Here’s a beginner-friendly Python example using the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from difflib import SequenceMatcher
def word_accuracy(ocr_text, ground_truth):
    """Rough word-level accuracy: how many words match, in order."""
    ocr_words = ocr_text.split()
    truth_words = ground_truth.split()
    matcher = SequenceMatcher(None, ocr_words, truth_words)
    matching_words = sum(block.size for block in matcher.get_matching_blocks())
    return round((matching_words / len(truth_words)) * 100, 2)

# Ground truth: what the document actually says
ground_truth = "Invoice number 48213 dated March 3 2026 total 214.50"
# OCR output: what the API returned
ocr_output = "lnvoice number 48213 dated March 3 2026 total 214.5O"
print(f"Word accuracy: {word_accuracy(ocr_output, ground_truth)}%")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To keep results organised by document type, wrap this in a small loop that stores scores in a dictionary:&lt;/p&gt;

&lt;p&gt;In this example, SequenceMatcher compares the OCR output with the ground truth and checks how many words match.&lt;/p&gt;

&lt;p&gt;This gives you a simple word-level accuracy score. If you later need more detailed character-level scoring, you can use&amp;nbsp;&lt;a href="https://en.wikipedia.org/wiki/Levenshtein_distance" rel="noopener noreferrer"&gt;Levenshtein distance&lt;/a&gt;&amp;nbsp;to measure the differences more precisely.&lt;/p&gt;

&lt;p&gt;Next, you can organise the results by document type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;results_by_type = {}
def record_result(doc_type, ocr_text, ground_truth):
    score = word_accuracy(ocr_text, ground_truth)
    results_by_type.setdefault(doc_type, []).append(score)

# After running every document through this...
for doc_type, scores in results_by_type.items():
    average = round(sum(scores) / len(scores), 1)
    print(f"{doc_type}: {average}% average ({len(scores)} documents)")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stores the scores for each document type separately and then calculates their average accuracy.&lt;/p&gt;

&lt;p&gt;The example is intentionally simple. It gives you a useful starting point without requiring a specialised OCR testing library.&lt;/p&gt;

&lt;p&gt;If you’re building the OCR pipeline that this benchmark will test,&amp;nbsp;&lt;a href="https://blog.filestack.com/implementing-scalable-cloud-based-ocr-filestack-comprehensive-guide/" rel="noopener noreferrer"&gt;implementing scalable cloud-based OCR&lt;/a&gt;&amp;nbsp;explains that process in more detail. If you want to learn more about character-level and word-level scoring, this&amp;nbsp;&lt;a href="https://towardsdatascience.com/evaluating-ocr-output-quality-with-character-error-rate-cer-and-word-error-rate-wer-853175297510/" rel="noopener noreferrer"&gt;walkthrough on CER and WER&lt;/a&gt;&amp;nbsp;is a useful next step.&lt;/p&gt;

&lt;p&gt;Run this same test against every document in your set, broken out by type, and you’ll end up with something like this:&lt;/p&gt;

&lt;p&gt;This example shows why separating results by document type matters. An OCR API could perform very well on clean invoices but struggle with handwritten notes. A single overall accuracy score could hide that difference.&lt;/p&gt;

&lt;p&gt;Once you have accuracy results for each document type, the next step is understanding what those numbers actually mean for your use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interpreting the Results
&lt;/h2&gt;

&lt;p&gt;Different accuracy scores across document types can tell you a lot about an OCR API.&lt;/p&gt;

&lt;p&gt;For example, if an OCR API works well with scanned forms but struggles with phone-photo receipts, you’ve found an area where it performs poorly. Whether that’s a major problem depends on how often your users upload that type of document.&lt;/p&gt;

&lt;p&gt;You should also look at confidence scores, but don’t rely on them alone. Check whether documents with low confidence scores are also the ones where your benchmark found real OCR errors.&lt;/p&gt;

&lt;p&gt;If low confidence scores often match real errors, they can be useful for identifying documents that may need extra review. If they don’t match your benchmark results, don’t use them as a replacement for measuring actual OCR accuracy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.filestack.com/filestack-ocr-feature/" rel="noopener noreferrer"&gt;Filestack’s OCR feature&lt;/a&gt;&amp;nbsp;is a useful reference if you want to see how confidence scoring can be used in a real product. For a more technical approach to comparing error rates across documents of different lengths, see the&amp;nbsp;&lt;a href="https://ocr-d.de/en/spec/ocrd_eval.html" rel="noopener noreferrer"&gt;OCR-D quality assurance methodology&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Once you have accuracy results for each document type and understand how useful the confidence scores are, you can start comparing OCR providers based on your actual needs.&lt;/p&gt;

&lt;h1&gt;
  
  
  Turning This Into a Vendor Comparison
&lt;/h1&gt;

&lt;p&gt;The main benefit of running this benchmark is that you can compare different OCR providers fairly.&lt;/p&gt;

&lt;p&gt;Use the exact same test documents, ground truth, and scoring method for every OCR API you’re considering. If you use different documents for each provider, the results won’t give you a fair comparison.&lt;/p&gt;

&lt;p&gt;Accuracy also shouldn’t be the only thing you compare. Consider factors such as processing speed, cost per document, and language support. For example, an OCR API that’s slightly less accurate but much faster or cheaper might still be a better choice for your use case.&lt;/p&gt;

&lt;p&gt;If you want to compare these factors in more detail,&amp;nbsp;&lt;a href="https://blog.filestack.com/choose-best-ocr-data-extraction-software-business/" rel="noopener noreferrer"&gt;how to choose the best OCR data extraction software for your business&lt;/a&gt;&amp;nbsp;explains what to consider beyond OCR accuracy.&lt;/p&gt;

&lt;p&gt;Once you’ve chosen a provider, there’s one more important thing to remember: OCR benchmarking shouldn’t be something you do only once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-Running the Benchmark Over Time
&lt;/h2&gt;

&lt;p&gt;The types and quality of documents you process can change over time. You might start receiving new form types, users may upload files from different devices, or your product may expand into markets with different document formats.&lt;/p&gt;

&lt;p&gt;Because of this, a benchmark you ran six months ago may no longer show how well your OCR API performs today.&lt;/p&gt;

&lt;p&gt;Mobile documents can also change differently from scanned documents. Phone cameras, lighting, and the way people take photos can all affect OCR accuracy. If many of your documents come from mobile devices,&amp;nbsp;&lt;a href="https://blog.filestack.com/choose-best-ocr-sdk-android-project-needs/" rel="noopener noreferrer"&gt;choosing the best OCR SDK for your Android project&lt;/a&gt;&amp;nbsp;explains what to consider for mobile OCR.&lt;/p&gt;

&lt;p&gt;Run the same benchmark regularly, such as every few months or whenever your document types change significantly. This helps you keep your OCR accuracy results up to date and make sure the API is still performing well on the documents your users actually submit.&lt;/p&gt;

&lt;p&gt;With the full benchmarking process covered, here are a few best practices and common mistakes to keep in mind.&lt;/p&gt;

&lt;h1&gt;
  
  
  Best Practices and Common Pitfalls
&lt;/h1&gt;

&lt;p&gt;Here are a few important things to remember when setting up your OCR benchmark:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do&lt;/strong&gt;&amp;nbsp;keep your ground truth separate from your testing process so it doesn’t accidentally get changed based on the OCR results.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do&lt;/strong&gt;&amp;nbsp;include difficult, low-quality documents that are similar to what your users might actually submit.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don’t&lt;/strong&gt;&amp;nbsp;combine different document types into one overall accuracy score. Keep the results separate for each type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don’t&lt;/strong&gt;&amp;nbsp;use different test sets when comparing OCR providers. Use the same documents and scoring method for a fair comparison.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don’t&lt;/strong&gt;&amp;nbsp;rely on a benchmark forever. Run it again when your document types or quality change over time.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping these points in mind will help you build a benchmark that gives you more reliable and useful results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;An OCR accuracy number is only useful if the test behind it is reliable. A vendor’s accuracy percentage can give you a starting point, but it may not show how well the OCR API will work with your documents.&lt;/p&gt;

&lt;p&gt;The best way to know is to test the API using your own documents and ground truth, and measure the results separately for each document type. It takes more time at the beginning, but it gives you results you can trust when choosing an OCR provider.&lt;/p&gt;

&lt;p&gt;If you’re ready to run your own OCR benchmark,&amp;nbsp;&lt;a href="https://www.filestack.com/products/filestack-capture/" rel="noopener noreferrer"&gt;Filestack Capture&lt;/a&gt;&amp;nbsp;is a good place to start testing your document set.&lt;/p&gt;

&lt;p&gt;If you still have questions about setting up and using an OCR accuracy benchmark, here are answers to some common ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why don’t vendor-quoted OCR accuracy numbers hold up on my own documents?
&lt;/h2&gt;

&lt;p&gt;Vendor accuracy numbers are usually based on clean, carefully selected test documents. A single overall percentage can also hide large differences between document types. Your own documents may be very different, so the OCR accuracy you get can also be different.&lt;/p&gt;

&lt;h2&gt;
  
  
  How many documents do I need for a meaningful OCR accuracy benchmark?
&lt;/h2&gt;

&lt;p&gt;Variety is more important than having a large number of documents. A smaller test set that includes your real document types, formats, and quality levels can give you more useful results than a large set of only clean documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  What’s “ground truth” in an OCR accuracy benchmark, and how do I establish it?
&lt;/h2&gt;

&lt;p&gt;Ground truth is a manually checked, correct version of the text in your test documents. You compare the OCR results against it to measure accuracy. Before testing, decide whether you’re measuring character-level or field-level accuracy, because each method counts errors differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I trust confidence scores instead of running a full accuracy benchmark?
&lt;/h2&gt;

&lt;p&gt;Confidence scores can be helpful, but don’t rely on them without testing first. Check whether low-confidence results actually match real OCR errors in your documents. If they do, you can use confidence scores as an extra signal for finding possible errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does an OCR accuracy benchmar
&lt;/h2&gt;

</description>
      <category>ocr</category>
      <category>api</category>
      <category>filestack</category>
    </item>
    <item>
      <title>How to Make React File Upload Progress and Errors Accessible</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Wed, 05 Aug 2026 11:27:39 +0000</pubDate>
      <link>https://dev.to/ideradevtools/how-to-make-react-file-upload-progress-and-errors-accessible-123c</link>
      <guid>https://dev.to/ideradevtools/how-to-make-react-file-upload-progress-and-errors-accessible-123c</guid>
      <description>&lt;p&gt;If you’ve already built a React file upload flow, it probably looks good and works well on a fast connection. But there’s one important area that many tutorials don’t cover: accessibility.&lt;/p&gt;

&lt;p&gt;For example, a drag-and-drop area might only work with a mouse, a progress bar might update on the screen without giving any updates to screen reader users, or an error message might appear in red without clearly showing which field caused the problem.&lt;/p&gt;

&lt;p&gt;This isn’t a general guide to accessibility. Instead, we’ll focus on improving a React upload flow you already have, whether it’s built with&amp;nbsp;&lt;a href="https://www.filestack.com/sdks/react/" rel="noopener noreferrer"&gt;Filestack’s React SDK&lt;/a&gt;&amp;nbsp;or your own custom components.&lt;/p&gt;

&lt;p&gt;You’ll learn how to make the drop zone, upload progress, and error messages easier to use for people who can’t see the screen or can’t use a mouse.&lt;/p&gt;

&lt;p&gt;If you’re working with large-file performance instead, our guide on&amp;nbsp;&lt;a href="https://blog.filestack.com/pause-resume-large-file-uploads-react-filestack/" rel="noopener noreferrer"&gt;pausing and resuming large file uploads in React&lt;/a&gt;&amp;nbsp;covers that topic. And if you want to learn about the same accessibility issues without focusing specifically on React,&amp;nbsp;&lt;a href="https://blog.filestack.com/html-file-upload-accessibility/" rel="noopener noreferrer"&gt;HTML file upload accessibility&lt;/a&gt;&amp;nbsp;is also worth reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Drag-and-drop doesn’t work with a keyboard by default, so every drop zone should also have a keyboard-friendly option.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A visual progress bar shows upload progress to sighted users, but screen reader users need ARIA updates to know what’s happening.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don’t announce every 1% change to screen reader users. Give progress updates at reasonable intervals to avoid too many announcements.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Error messages should be properly connected to the file or field that caused the error, not just displayed nearby.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tools like axe and Lighthouse can find issues such as missing labels, but testing with a real screen reader is important to make sure the whole upload experience is easy to understand.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these key points in mind, let’s start with one of the most common accessibility problems in file uploads: drag-and-drop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Drag-and-Drop Upload Zones Are an Accessibility Blind Spot
&lt;/h2&gt;

&lt;p&gt;Drag-and-drop feels simple and modern, but it can be difficult to use for people who don’t use a mouse or trackpad.&lt;/p&gt;

&lt;p&gt;The main problem is that dragging and dropping is a mouse-based action. There isn’t a built-in keyboard version of dragging a file into a drop zone. If your drop zone only uses&amp;nbsp;&lt;code&gt;onDragOver&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;onDrop&lt;/code&gt;&amp;nbsp;events, keyboard users may not be able to use it at all.&lt;/p&gt;

&lt;p&gt;Most drop zones also use visual changes to show when they’re active, such as changing the border when a file is dragged over them. A screen reader can’t detect or announce this visual change on its own.&lt;/p&gt;

&lt;p&gt;You might think adding a “click to browse” option solves the problem. It helps, but that option also needs to be accessible with a keyboard, have a clear label, and use the same progress and error handling as the drag-and-drop option.&lt;/p&gt;

&lt;p&gt;The&amp;nbsp;&lt;a href="https://www.w3.org/TR/WCAG22/#dragging-movements" rel="noopener noreferrer"&gt;W3C’s guidance on dragging movements&lt;/a&gt;&amp;nbsp;explains that actions that depend on dragging should also have a simpler alternative that doesn’t require a drag gesture.&lt;/p&gt;

&lt;p&gt;Once you have that alternative, the next step is making sure both the fallback and the drop zone are easy to use with a keyboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making the Drop Zone Keyboard-Operable
&lt;/h2&gt;

&lt;p&gt;Making a drop zone keyboard-friendly is less about ARIA and more about making sure users can reach and use it without a mouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reachable and Triggerable via Keyboard
&lt;/h2&gt;

&lt;p&gt;A native&amp;nbsp;&lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;&amp;nbsp;already works with a keyboard. Users can tab to it and press Enter or Space to open the file picker.&lt;/p&gt;

&lt;p&gt;Problems usually happen when you create a custom drop zone using a styled&amp;nbsp;&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;&amp;nbsp;with a hidden file input. If the&amp;nbsp;&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;&amp;nbsp;isn’t keyboard-accessible, users may tab past it without knowing it’s there.&lt;/p&gt;

&lt;p&gt;Here’s a simple example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// A simple, keyboard-reachable drop zone
function DropZone({ onFilesSelected }) {
const inputRef = useRef(null);
const openFilePicker = () =&amp;gt; inputRef.current.click();
const handleKeyDown = (event) =&amp;gt; {
// Enter or Space should behave like a click
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openFilePicker();
}
};
return (
&amp;lt;div
role="button"
tabIndex="0"
onClick={openFilePicker}
onKeyDown={handleKeyDown}
onDrop={(e) =&amp;gt; {
e.preventDefault();
onFilesSelected(e.dataTransfer.files);
}}
onDragOver={(e) =&amp;gt; e.preventDefault()}
className="drop-zone"
&amp;gt;
&amp;lt;p&amp;gt;Drag a file here, or press Enter to choose one&amp;lt;/p&amp;gt;
&amp;lt;input
ref={inputRef}
type="file"
hidden
onChange={(e) =&amp;gt; onFilesSelected(e.target.files)}
/&amp;gt;
&amp;lt;/div&amp;gt;
);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this example,&amp;nbsp;&lt;code&gt;tabIndex="0"&lt;/code&gt;&amp;nbsp;lets keyboard users reach the drop zone. The&amp;nbsp;&lt;code&gt;handleKeyDown&lt;/code&gt;&amp;nbsp;function also lets them press Enter or Space to open the file picker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visible Focus Indicators
&lt;/h2&gt;

&lt;p&gt;Making the drop zone keyboard-accessible isn’t enough. Users also need to clearly see when it has keyboard focus.&lt;/p&gt;

&lt;p&gt;Avoid removing the default focus outline with&amp;nbsp;&lt;code&gt;outline: none&lt;/code&gt;&amp;nbsp;unless you replace it with another clear focus style.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.drop-zone:focus-visible {
outline: 3px solid #EF4A25;
outline-offset: 2px;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now keyboard users can reach the drop zone, open the file picker, and clearly see when the drop zone is focused.&lt;/p&gt;

&lt;p&gt;Once the file is selected, the next step is making sure users can also understand how the upload is progressing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Announcing Upload Progress to Screen Reader Users
&lt;/h2&gt;

&lt;p&gt;A progress bar might look clear on the screen, but that doesn’t mean every user knows what’s happening.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Progress Bar Alone Isn’t Enough
&lt;/h2&gt;

&lt;p&gt;A&amp;nbsp;&lt;code&gt;&amp;lt;progress&amp;gt;&lt;/code&gt;&amp;nbsp;element or a styled&amp;nbsp;&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;&amp;nbsp;can visually show how much of a file has uploaded. But screen reader users may not know that the progress is changing unless those updates are announced.&lt;/p&gt;

&lt;p&gt;Without these announcements, they may not know whether the upload has started, how far it has progressed, or when it has finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using ARIA Live Regions Without Creating Noise
&lt;/h2&gt;

&lt;p&gt;An ARIA live region lets screen readers know when important content on the page changes. For an upload, you can use it to announce progress as the percentage increases.&lt;/p&gt;

&lt;p&gt;However, you shouldn’t announce every single percentage change. Hearing “1%… 2%… 3%…” can quickly become distracting. Instead, announce progress at larger intervals, such as every 10%.&lt;/p&gt;

&lt;p&gt;You can learn more about how this works in&amp;nbsp;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions" rel="noopener noreferrer"&gt;MDN’s guide to ARIA live regions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here’s a simple example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function UploadStatus({ progress, isComplete }) {
const [announcement, setAnnouncement] = useState('');
useEffect(() =&amp;gt; {
if (isComplete) {
setAnnouncement('Upload complete.');
return;
}
// Only announce at 10% steps, not every single percent
if (progress % 10 === 0) {
setAnnouncement(`Upload ${progress}% complete.`);
}
}, [progress, isComplete]);
return (
&amp;lt;div&amp;gt;
&amp;lt;progress value={progress} max="100" /&amp;gt;
{/* This div is what screen readers listen to */}
&amp;lt;div aria-live="polite" className="visually-hidden"&amp;gt;
{announcement}
&amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Announcing Completion Clearly
&lt;/h2&gt;

&lt;p&gt;When the upload finishes, give users a clear message such as “Upload complete.”&lt;/p&gt;

&lt;p&gt;Don’t rely only on the final “100%” progress update. A separate completion message makes it clear that the upload has successfully finished.&lt;/p&gt;

&lt;p&gt;But progress updates are only one part of the experience. You also need to make sure users clearly understand when an upload fails and what caused the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making Error States Accessible
&lt;/h2&gt;

&lt;p&gt;Error messages are another important part of an accessible upload flow. A common problem is that the error message appears near the file input visually, but isn’t properly connected to it for screen reader users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Associating Errors with aria-describedby
&lt;/h2&gt;

&lt;p&gt;Putting an error message below a file input makes the connection clear to someone looking at the screen. But a screen reader may not know that the error belongs to that input.&lt;/p&gt;

&lt;p&gt;You can use aria-describedby to connect the file input to its error message. This helps screen readers understand and announce the relationship between them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function FileInputWithError({ error }) {
return (
&amp;lt;div&amp;gt;
&amp;lt;input
id="resume-file"
type="file"
aria-describedby={error ? 'resume-file-error' : undefined}
aria-invalid={Boolean(error)}
/&amp;gt;
{error &amp;amp;&amp;amp; (
&amp;lt;p id="resume-file-error" role="alert"&amp;gt;
{error}
&amp;lt;/p&amp;gt;
)}
&amp;lt;/div&amp;gt;
);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here,&amp;nbsp;&lt;code&gt;aria-describedby&lt;/code&gt;&amp;nbsp;connects the input to the error message.&amp;nbsp;&lt;code&gt;aria-invalid&lt;/code&gt;&amp;nbsp;tells assistive technology that the input currently has an error.&lt;/p&gt;

&lt;p&gt;The error also uses&amp;nbsp;&lt;code&gt;role="alert"&lt;/code&gt;, which helps screen readers announce the message when it appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Announcing Errors as They Happen
&lt;/h2&gt;

&lt;p&gt;Don’t wait until the user submits the form to announce an error.&lt;/p&gt;

&lt;p&gt;For example, if someone selects a file that’s too large or uses the wrong file type, show and announce the error as soon as the file is rejected. This lets the user know immediately what went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing Error Messages That Make Sense Out of Context
&lt;/h2&gt;

&lt;p&gt;Avoid unclear messages such as “Error: invalid input.” They don’t explain what went wrong or how to fix it.&lt;/p&gt;

&lt;p&gt;Instead, use a specific message such as “This file is 45MB, but the limit is 25MB.” This tells the user exactly what the problem is and helps them choose a suitable file.&lt;/p&gt;

&lt;p&gt;Once your drop zone, progress updates, and error messages are accessible on their own, the next step is bringing them together in a single component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing This in a React Component
&lt;/h2&gt;

&lt;p&gt;Now, let’s bring everything we’ve covered into one React upload component.&lt;/p&gt;

&lt;p&gt;This is a simple example. A real-world uploader will usually have more file-handling logic, but the accessibility setup will remain similar.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function AccessibleUploader() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState('idle'); // idle | uploading | success | error
const [error, setError] = useState(null);
const [liveMessage, setLiveMessage] = useState('');
const successRef = useRef(null);
const handleFiles = (files) =&amp;gt; {
const file = files[0];
if (file.size &amp;gt; 25 * 1024 * 1024) {
setStatus('error');
setError('This file is larger than the 25MB limit.');
return;
}
setStatus('uploading');
setError(null);
// Upload logic (e.g. calling Filestack's upload method) would go here,
// calling setProgress(...) as it reports progress.
};
useEffect(() =&amp;gt; {
if (status === 'success') {
setLiveMessage('Upload complete.');
// Move focus somewhere sensible once the upload finishes
successRef.current?.focus();
} else if (status === 'uploading' &amp;amp;&amp;amp; progress % 10 === 0) {
setLiveMessage(`Upload ${progress}% complete.`);
}
}, [status, progress]);
return (
&amp;lt;div&amp;gt;
&amp;lt;DropZone onFilesSelected={handleFiles} /&amp;gt;
&amp;lt;div aria-live="polite" className="visually-hidden"&amp;gt;
{liveMessage}
&amp;lt;/div&amp;gt;
{status === 'uploading' &amp;amp;&amp;amp; &amp;lt;progress value={progress} max="100" /&amp;gt;}
{status === 'error' &amp;amp;&amp;amp; (
&amp;lt;p id="upload-error" role="alert"&amp;gt;
{error}
&amp;lt;/p&amp;gt;
)}
{status === 'success' &amp;amp;&amp;amp; (
&amp;lt;p tabIndex="-1" ref={successRef}&amp;gt;
Your file uploaded successfully.
&amp;lt;/p&amp;gt;
)}
&amp;lt;/div&amp;gt;
);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this code does:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Tracks the upload progress using the progress state.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Tracks whether the upload is idle, uploading, success, or error.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Checks the file size before starting the upload and shows an error if the file is larger than 25MB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uses an ARIA live region to announce upload progress to screen reader users.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Announces progress every 10% instead of announcing every small change.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Announces when the upload is complete.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Shows an error message with&amp;nbsp;&lt;code&gt;role="alert"&lt;/code&gt;&amp;nbsp;if something goes wrong.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Moves keyboard focus to the success message after the upload finishes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uses the accessible DropZone component created earlier for selecting files.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Managing the Live Region Without Over-Announcing
&lt;/h2&gt;

&lt;p&gt;In the above example, all screen reader announcements are stored in one state variable called&amp;nbsp;&lt;code&gt;liveMessage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This makes it easier to control when a new message is announced. Instead of announcing every small progress change, the component only updates the message at useful points, such as every 10%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Focus Management After Upload Finishes or Fails
&lt;/h2&gt;

&lt;p&gt;After an upload finishes, you can move keyboard focus to the success message using&amp;nbsp;&lt;code&gt;tabIndex="-1"&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;.focus(&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;This helps screen reader users immediately understand that the upload has finished instead of leaving their focus on the drop zone.&lt;/p&gt;

&lt;p&gt;The same idea can also be used for errors. If the upload fails, you can move focus to the error message so the user knows what happened and what they should do next.&lt;/p&gt;

&lt;p&gt;Managing states such as uploading, success, and error becomes even more important as your upload component grows. The patterns discussed in&amp;nbsp;&lt;a href="https://blog.filestack.com/how-you-can-fix-the-biggest-problem-with-react-file-upload/" rel="noopener noreferrer"&gt;how you can fix the biggest problem with React file upload&lt;/a&gt;&amp;nbsp;can help when you’re working with a more complex upload flow.&lt;/p&gt;

&lt;p&gt;Once the code is in place, the next step is testing it with the same tools and interactions your users rely on.&lt;/p&gt;

&lt;h1&gt;
  
  
  Testing With a Real Screen Reader, Not Just a Linter
&lt;/h1&gt;

&lt;p&gt;Automated accessibility tools are useful, but a clean report doesn’t always mean your upload flow is fully accessible.&lt;/p&gt;

&lt;h1&gt;
  
  
  What Automated Tools Catch
&lt;/h1&gt;

&lt;p&gt;Tools like axe and Lighthouse can find common accessibility problems, such as missing labels, missing alt text, poor color contrast, or inputs without accessible names.&lt;/p&gt;

&lt;p&gt;It’s a good idea to run these tools regularly because they can quickly catch basic issues.&amp;nbsp;&lt;a href="https://webaim.org/techniques/aria/" rel="noopener noreferrer"&gt;WebAIM’s introduction to ARIA&lt;/a&gt;&amp;nbsp;is also a useful resource for understanding how ARIA roles and attributes should work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Only Manual Testing Catches
&lt;/h2&gt;

&lt;p&gt;Automated tools can’t tell you everything. For example, they can’t always tell whether progress updates are announced at the right time, whether an error message makes sense when heard without seeing the screen, or whether the keyboard navigation feels natural.&lt;/p&gt;

&lt;p&gt;That’s why you should also test the upload flow with a real screen reader such as NVDA or VoiceOver. Try going through the entire upload process without using a mouse.&lt;/p&gt;

&lt;p&gt;Check whether you can select a file with the keyboard, understand the upload progress, hear error messages clearly, and know when the upload has finished.&lt;/p&gt;

&lt;p&gt;Using both automated tools and manual testing gives you a much better idea of how accessible your upload flow really is.&lt;/p&gt;

&lt;p&gt;With testing covered, let’s look at some common mistakes to avoid when building an accessible file upload experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Practices and Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;Here are a few important things to remember when making your React file upload accessible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Make sure the upload works with a keyboard first, then add drag-and-drop support.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Announce upload progress at reasonable intervals, such as every 10–20%, instead of every small change.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Use clear messages for both successful and failed uploads.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Move focus to the success or error message when the upload finishes so users know what happened.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Test the complete upload flow with a real screen reader before considering it finished.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Don’t use only a colored border or icon to show an error. Include a clear text message and connect it to the correct input.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don’t remove the default focus outline unless you replace it with another visible focus style.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don’t update an&amp;nbsp;&lt;code&gt;aria-live&lt;/code&gt;&amp;nbsp;region on every progress change. Too many announcements can make the experience difficult to follow.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don’t assume a “click to browse” option is automatically accessible. It still needs clear labeling and keyboard support.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don’t rely only on automated accessibility tools. Use them as a first check, then test the experience manually with a screen reader.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these best practices and common mistakes in mind, it’s also worth looking at how newer SDK updates can support the React upload flows you’re building.&lt;/p&gt;

&lt;h1&gt;
  
  
  What’s New in Filestack’s React Support
&lt;/h1&gt;

&lt;p&gt;If you’re using Filestack’s React SDK for your upload flow, it’s worth keeping up with the latest updates.&lt;/p&gt;

&lt;p&gt;Filestack&amp;nbsp;&lt;a href="https://blog.filestack.com/filestack-react-sdk-v7-0-0/" rel="noopener noreferrer"&gt;React SDK v7.0.0 release&lt;/a&gt;&amp;nbsp;brought several improvements, including full TypeScript support, React 19 support, and better compatibility with frameworks like Next.js, Vite, and Remix.&lt;/p&gt;

&lt;p&gt;As the SDK continues to change, the accessibility features you add should continue to work with newer versions.&amp;nbsp;&lt;a href="https://blog.filestack.com/future-proofing-react-file-uploader/" rel="noopener noreferrer"&gt;Future-proofing your React file uploader&lt;/a&gt;&amp;nbsp;is a useful next read for keeping your uploader up to date as React and the SDK evolve.&lt;/p&gt;

&lt;p&gt;Whether you’re using Filestack or your own React components, the main accessibility principles stay the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;You don’t need to rebuild your React upload flow to make it more accessible. Most of the work is about fixing small things that are easy to miss when testing only with a mouse and screen.&lt;/p&gt;

&lt;p&gt;Make sure users can reach and use the drop zone with a keyboard, provide clear announcements for upload progress and errors, and connect error messages to the correct fields.&lt;/p&gt;

&lt;p&gt;With these changes, your existing&amp;nbsp;&lt;a href="https://www.filestack.com/sdks/react/" rel="noopener noreferrer"&gt;React upload component&lt;/a&gt;&amp;nbsp;becomes easier to use for more people without changing the experience for other users.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Is a drag-and-drop file upload zone accessible by default?
&lt;/h2&gt;

&lt;p&gt;No. Drag-and-drop doesn’t have a built-in keyboard option. Your drop zone should also provide an accessible alternative, such as a clearly labeled file input that users can reach and use with only a keyboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I announce upload progress to screen reader users in React?
&lt;/h2&gt;

&lt;p&gt;Use an ARIA live region to announce upload progress to screen reader users, but don’t announce every percentage change. Instead, give updates every 10–20% to avoid too many announcements. When the upload finishes, announce a separate message like “Upload complete.”&lt;/p&gt;

&lt;h2&gt;
  
  
  How should error messages be associated with the file that failed to upload?
&lt;/h2&gt;

&lt;p&gt;Use aria-describedby to connect the error message to the correct file or field. Simply placing the error message nearby isn’t enough because screen readers may not understand that they’re related.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is automated accessibility testing enough to confirm an upload flow is accessible?
&lt;/h2&gt;

&lt;p&gt;No. Tools like axe or Lighthouse can find issues such as missing labels and other accessibility problems, but they can’t check everything. They can’t tell whether progress updates are announced at the right time or whether an error message makes sense without seeing the screen. That’s why you should also test your upload flow manually with a real screen reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does having a file-picker fallback next to a drag-and-drop zone make the whole flow accessible?
&lt;/h2&gt;

&lt;p&gt;Not automatically. The fallback also needs to be clearly labeled and accessible with a keyboard. It should also use the same progress updates and error announcements as the drag-and-drop option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Originally published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/accessible-react-file-upload/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
      <category>react</category>
      <category>fileupload</category>
    </item>
    <item>
      <title>The Best Free CDN for Images Options Compared</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:16:13 +0000</pubDate>
      <link>https://dev.to/ideradevtools/the-best-free-cdn-for-images-options-compared-l97</link>
      <guid>https://dev.to/ideradevtools/the-best-free-cdn-for-images-options-compared-l97</guid>
      <description>&lt;p&gt;A free CDN for images caches your files near your users, so images arrive faster. The five worth comparing split cleanly: Filestack does real processing on its free tier, Uploadcare is stricter but cleaner,&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Filestack gives 1 GB free with nothing watermarked or restricted.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;imgix has no free plan, only a 30 day trial.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uploadcare’s free tier is personal use only, so you cannot ship on it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Transloadit watermarks every image on its free plan.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cloudinary meters in credits, not GB, so plans do not compare directly.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Free image CDN plans compared
&lt;/h2&gt;

&lt;p&gt;Read this table first, because every number was taken from the vendor’s own pricing page in July 2026. Pricing moves, so check the current page before you commit to a plan.&lt;/p&gt;

&lt;p&gt;Option What the free plan gives you The limit that actually bites Commercial use allowed&lt;br&gt;&lt;br&gt;
Filestack 1 GB bandwidth, 500 uploads, 1,000 transformations, 1 GB storage 1 GB bandwidth, and one team member Yes&lt;br&gt;&lt;br&gt;
Cloudinary 25 monthly credits, 3 users Credits are not GB, so you cannot compare plans directly Yes&lt;br&gt;&lt;br&gt;
Uploadcare 1,000 operations, 1 GB storage, 5 GB traffic, 500 MB max file Personal use only No&lt;br&gt;&lt;br&gt;
Transloadit 5 GB of processing every month Output images are watermarked Yes, with a watermark&lt;br&gt;&lt;br&gt;
imgix No free plan. 100 credits for 30 days The trial ends, then it is $25 a month Trial only&lt;/p&gt;

&lt;p&gt;Two rows in that table deserve a second look, because they are easy to miss and expensive to discover late.&lt;/p&gt;

&lt;p&gt;Uploadcare’s free plan is generous on traffic, but it is also labeled personal use only, which means it cannot legally sit underneath a commercial product.&lt;/p&gt;

&lt;p&gt;Transloadit gives you 5 GB a month free forever, but every image it returns on that plan carries a Transloadit watermark, which is fine for a prototype and not something you can ship.&lt;/p&gt;

&lt;p&gt;Filestack’s free plan is the smallest on bandwidth at 1 GB and is capped at one team member. Nothing in it is watermarked or restricted to personal use, so what you build on it is what you ship.&lt;/p&gt;
&lt;h2&gt;
  
  
  How we evaluated these
&lt;/h2&gt;

&lt;p&gt;We compared four things, and we weighed them in this order.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The real limit.&lt;/strong&gt;&amp;nbsp;Not the headline number, the one you hit first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Whether you can ship it.&lt;/strong&gt;&amp;nbsp;A watermark or a personal use clause is a hard stop.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;What happens past caching.&lt;/strong&gt;&amp;nbsp;Delivery is one step. Most teams also need resizing and format conversion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The cost of leaving.&lt;/strong&gt;&amp;nbsp;How much code changes if you switch later.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We did not rank on raw network size, because every option here runs on a large global network and that number stopped being a differentiator years ago. If you want the wider infrastructure view, our&amp;nbsp;&lt;a href="https://blog.filestack.com/best-cdn-providers-overview" rel="noopener noreferrer"&gt;best CDN providers content delivery network&lt;/a&gt;&amp;nbsp;roundup covers the general purpose CDNs.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to look for in a free cdn for images
&lt;/h2&gt;

&lt;p&gt;A cache alone will not fix a slow page, because if your server sends a 3 MB PNG then the CDN just sends that same 3 MB PNG faster. The work that actually shrinks the page is resizing and format conversion.&lt;/p&gt;

&lt;p&gt;Most sites still have not done this. The&amp;nbsp;&lt;a href="https://almanac.httparchive.org/en/2024/media" rel="noopener noreferrer"&gt;2024 Web Almanac media chapter&lt;/a&gt;&amp;nbsp;found WebP on only 12 percent of images across the crawled web, and AVIF on just 1 percent. JPEG still holds 32.4 percent, down from 40 percent in 2022. So the format win is real and largely unclaimed.&amp;nbsp;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Formats/Image_types" rel="noopener noreferrer"&gt;MDN’s image format guide&lt;/a&gt;&amp;nbsp;covers which browsers take what, and&amp;nbsp;&lt;a href="https://web.dev/articles/image-cdns" rel="noopener noreferrer"&gt;web.dev’s guide to image CDNs&lt;/a&gt;&amp;nbsp;explains the pattern independently of any vendor.&lt;/p&gt;

&lt;p&gt;So there are three things worth looking for beyond the cache itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Automatic format negotiation.&lt;/strong&gt;&amp;nbsp;The CDN should send WebP to browsers that take it, and the original format to browsers that do not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Resize on the URL.&lt;/strong&gt;&amp;nbsp;You should not need a build step to get a 400 pixel thumbnail.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A path past images.&lt;/strong&gt;&amp;nbsp;Most apps that accept images also accept PDFs and video eventually.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our post on&amp;nbsp;&lt;a href="https://blog.filestack.com/boosting-website-performance-free-image-cdns-supercharge-site-speed" rel="noopener noreferrer"&gt;free image CDN website speed performance&lt;/a&gt;&amp;nbsp;goes deeper on the speed side. For the setup steps we skip here, see&amp;nbsp;&lt;a href="https://blog.filestack.com/high-performance-free-images-cdn" rel="noopener noreferrer"&gt;free image cdn performance optimization delivery&lt;/a&gt;. If you are new to CDNs entirely, start with&amp;nbsp;&lt;a href="https://blog.filestack.com/understanding-and-implementing-a-free-cdn-a-developers-guide" rel="noopener noreferrer"&gt;understanding free CDNs a developers guide&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pricing once you outgrow free
&lt;/h2&gt;

&lt;p&gt;Free tiers end, so here is the first paid step for each option, letting you see the cliff before you walk off it.&lt;/p&gt;

&lt;p&gt;Option Entry paid plan What you get&lt;br&gt;&lt;br&gt;
imgix Starter $25 a month 100 credits, up to 50 GB media, 100 GB delivery&lt;br&gt;&lt;br&gt;
Transloadit Startup $54 a month billed annually 40 GB a month, 5 GB max file, $1.80 per GB over&lt;br&gt;&lt;br&gt;
Filestack Start $69 a month 75 GB bandwidth, 20,000 uploads, 50,000 transformations, 50 GB storage&lt;/p&gt;

&lt;p&gt;Filestack is not the cheapest line on that table and pretending otherwise would be silly. What the $69 provides is a larger surface, because the upload path, the processing engine, and delivery all sit behind a single key.&lt;/p&gt;

&lt;p&gt;One detail matters more than it looks: on Filestack a transformation is cached for 30 days, and every view inside that window counts as one transformation. So 50,000 transformations is not 50,000 page views, it is 50,000 distinct image variants.&lt;/p&gt;
&lt;h2&gt;
  
  
  See the format switch happen
&lt;/h2&gt;

&lt;p&gt;Here is one photo of a golden retriever puppy delivered two ways, from the same source file at the same 600 pixel width. Look at them, and then look at the file sizes underneath.&lt;/p&gt;

&lt;p&gt;Delivered as JPEGDelivered as WebP&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Content-Type&lt;/strong&gt;image/jpegimage/webp*&lt;em&gt;Dimensions&lt;/em&gt;&lt;em&gt;600 x 427600 x 427&lt;/em&gt;&lt;em&gt;Size on the wire&lt;/em&gt;&lt;em&gt;41,259 bytes29,628 bytes&lt;/em&gt;&lt;em&gt;File handle&lt;/em&gt;&lt;em&gt;57mEl6UeRNaEppLwJUJj57mEl6UeRNaEppLwJUJj&lt;/em&gt;&lt;em&gt;Transform path&lt;/em&gt;*resize=width:600/output=format:jpg,quality:80resize=width:600/output=format:webp,quality:80&lt;/p&gt;

&lt;p&gt;Read the handle row again, because it is the same handle on both sides. You uploaded one file and the two deliveries are simply two different paths in front of it, so nothing was pre-generated and nothing extra was stored.&lt;/p&gt;

&lt;p&gt;Neither variant above required a build step, and both are live, so right click either image and check the format yourself.&lt;/p&gt;
&lt;h1&gt;
  
  
  What the CDN knows about the file
&lt;/h1&gt;

&lt;p&gt;You do not have to take the source numbers on faith, because the same URL pattern returns the stored file’s metadata as JSON, which is useful when you are debugging what actually landed.&lt;/p&gt;

&lt;p&gt;curl -s “&lt;a href="https://cdn.filestackcontent.com/metadata/57mEl6UeRNaEppLwJUJj%E2%80%9D" rel="noopener noreferrer"&gt;https://cdn.filestackcontent.com/metadata/57mEl6UeRNaEppLwJUJj”&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;# {“filename”:”golden-retriever-source.jpg”,”mimetype”:”image/jpeg”,&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;# “size”:608073,”uploaded”:1784784574350.922,”writeable”:true}&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;curl -s “&lt;a href="https://cdn.filestackcontent.com/imagesize/57mEl6UeRNaEppLwJUJj%E2%80%9D" rel="noopener noreferrer"&gt;https://cdn.filestackcontent.com/imagesize/57mEl6UeRNaEppLwJUJj”&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;# {“height”:1552,”width”:2180}&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;PropertyValueWhere it came fromFilenamegolden-retriever-source.jpgmetadataMIME typeimage/jpegmetadataStored size608,073 bytesmetadataUploaded2026–07–23 05:29 UTCmetadataDimensions2180 x 1552imagesize&lt;/p&gt;

&lt;p&gt;So the source is a 594 KB camera JPEG while the WebP the browser actually receives is 29,628 bytes, which is the whole delivery story in two numbers.&lt;/p&gt;
&lt;h2&gt;
  
  
  Watch it resize
&lt;/h2&gt;

&lt;p&gt;Width is just a number in the URL, so you change the number, get a different image, and store nothing extra.&lt;/p&gt;

&lt;p&gt;width:200width:600&lt;/p&gt;

&lt;p&gt;8969 bytes (jpeg)29,628 bytes (jpeg)&lt;/p&gt;

&lt;p&gt;At 1200 pixels that same URL returns 133,619 bytes, so a phone asks for the small one and a desktop asks for the large one, and your server never generated either of them.&lt;/p&gt;

&lt;p&gt;One honest note on formats: we did not compare against PNG here, because PNG would flatter WebP unfairly. That same photo as a PNG is 437,352 bytes, more than ten times the JPEG, since PNG is the right format for flat graphics and screenshots rather than photographs. Compare like for like or the number means nothing.&lt;/p&gt;

&lt;p&gt;Now the part that matters for delivery. Since you do not want to pick the format per browser by hand, ask for one URL two ways and watch the server decide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Same URL both times. The only difference is what the browser says it accepts.

curl -s -o /dev/null -w "%{content_type} %{size_download} bytes\n" \
  -H "Accept: image/webp" \
  "https://cdn.filestackcontent.com/auto_image/resize=width:1200/output=quality:80/57mEl6UeRNaEppLwJUJj"
# image/webp 148140 bytes
curl -s -o /dev/null -w "%{content_type} %{size_download} bytes\n" \
  -H "Accept: image/jpeg" \
  "https://cdn.filestackcontent.com/auto_image/resize=width:1200/output=quality:80/57mEl6UeRNaEppLwJUJj"
# image/jpeg 210896 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are real responses from the same puppy photo above, at 1200 pixels wide. The auto_image task reads the Accept header and picks the format. A modern browser gets 145 KB of WebP. An older one still gets a working JPEG, no fallback logic in your code.&lt;/p&gt;

&lt;p&gt;This is not a vendor trick. It is proactive content negotiation, defined in&amp;nbsp;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.1" rel="noopener noreferrer"&gt;RFC 9110 section 12.1&lt;/a&gt;, and the Accept header it depends on is&amp;nbsp;&lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1" rel="noopener noreferrer"&gt;section 12.5.1&lt;/a&gt;&amp;nbsp;of the same spec. Any CDN can do it. The question to ask a vendor is whether it is on by default or something you configure per image.&lt;/p&gt;

&lt;p&gt;You never wrote a build step, and you never stored a second copy.&lt;/p&gt;

&lt;p&gt;Here is the whole loop in JavaScript. Install, upload, transform, deliver.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// npm install filestack-js@3.51.6
// Tested against filestack-js 3.51.6.
import * as filestack from 'filestack-js';

const client = filestack.init('YOUR_API_KEY');
async function uploadAndDeliver(file) {
  try {
    const res = await client.upload(file, {
      retry: 3, // retries on a flaky connection instead of failing the upload
      onProgress: (evt) =&amp;gt; console.log(`${evt.totalPercent}%`),
    });
    // res looks like:
    // { handle: '57mEl6UeRNaEppLwJUJj', url: 'https://cdn.filestackcontent.com/...',
    //   filename: 'puppy.jpg', size: 608073, mimetype: 'image/jpeg' }
    return `https://cdn.filestackcontent.com/auto_image/resize=width:1200/${res.handle}`;
  } catch (err) {
    // upload errors expose the response so you can log something useful
    console.error('Upload failed:', err.message);
    throw err;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the full path from a file the user picked to a URL that serves an optimized image. The chunked, resumable, retry-configurable upload behind client.upload is the&amp;nbsp;&lt;a href="https://blog.filestack.com/optimize-file-delivery-workflow-filestacks-integration-tools" rel="noopener noreferrer"&gt;file delivery&lt;/a&gt;&amp;nbsp;story starting at the beginning, not at the cache.&lt;/p&gt;

&lt;h1&gt;
  
  
  What the same job costs to hand roll
&lt;/h1&gt;

&lt;p&gt;You can of course build all of this yourself, and here is roughly what it takes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// The DIY version, compressed to its outline.
import sharp from 'sharp';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
// 1. Accept the upload and stream it somewhere. Handle retries yourself.
// 2. Decide which variants you need, up front, because you must store each one.
const variants = [400, 800, 1200];
for (const width of variants) {
  for (const format of ['webp', 'jpeg']) {
    const buf = await sharp(input).resize({ width })[format]().toBuffer();
    await s3.send(new PutObjectCommand({ Key: `img/${id}-${width}.${format}`, Body: buf }));
  }
}
// 3. Write the &amp;lt;picture&amp;gt; element logic to pick a variant per browser.
// 4. Invalidate the CDN cache when the source changes.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is six stored files per upload instead of one, plus the negotiation logic and the cache invalidation. It works, but it is also a service you now have to maintain. The tradeoff is real, and for a team with an infrastructure engineer it can be the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using an image CDN for wordpress
&lt;/h2&gt;

&lt;p&gt;WordPress changes the shape of this slightly, because you are usually not calling an SDK at all. Instead you are rewriting URLs in the media library so that they point at the CDN.&lt;/p&gt;

&lt;p&gt;The rule stays the same: pick something that rewrites to a transform URL rather than just a cached copy, so the theme can ask for the width it needs. If you serve a single full size image to a phone, a CDN will not save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which is the best CDN for images for your stack
&lt;/h2&gt;

&lt;p&gt;Here is the honest split between them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick Filestack&lt;/strong&gt;&amp;nbsp;if you want ingest, processing, and delivery from one key, and you expect to handle more than images later. The free tier is small on bandwidth, but nothing in it is crippled. Our&amp;nbsp;&lt;a href="https://blog.filestack.com/high-performance-free-images-cdn" rel="noopener noreferrer"&gt;Filestack CDN global file delivery architecture&lt;/a&gt;&amp;nbsp;post covers how the delivery layer fits together. The&amp;nbsp;&lt;a href="https://www.filestack.com/products/deliver-images/" rel="noopener noreferrer"&gt;deliver images product page&lt;/a&gt;&amp;nbsp;has the full capability list.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consider Cloudinary&lt;/strong&gt;&amp;nbsp;if media is your product and you need deep video and image intelligence at scale. That is Cloudinary’s lane and they are very good in it. You will spend time learning the credit model.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consider Transloadit&lt;/strong&gt;&amp;nbsp;if you need broad raw format support or a portable, self hostable pipeline. Just budget for a paid plan from day one, because the free watermark rules out shipping.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consider Uploadcare&lt;/strong&gt;&amp;nbsp;if you want lean, security first delivery of user generated content and your use is genuinely personal, or you are ready to pay.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consider imgix&lt;/strong&gt;&amp;nbsp;if you have an existing image library in S3 and you only want a delivery and transformation layer over it. There is no free tier, so decide with the $25 plan in mind.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a single page app, the delivery layer is only half the win. The other half is not blocking the main thread during upload, which we cover in&amp;nbsp;&lt;a href="https://blog.filestack.com/optimizing-angular-apps-efficient-file-delivery-uploads" rel="noopener noreferrer"&gt;Webpack file delivery optimization&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;The puppy photo used in the format demo is public domain, from&amp;nbsp;&lt;a href="https://commons.wikimedia.org/wiki/File:Golden_Retriever_-_7_weeks.jpg" rel="noopener noreferrer"&gt;Wikimedia Commons&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Once delivery is sorted, the next question is usually what happens to a file between the upload and the CDN. That is where the loop closes. Files get scanned, converted, cropped to faces, or run through OCR before they are ever served, and that all happens behind the same key. Start with the&amp;nbsp;&lt;a href="https://blog.filestack.com/optimize-file-delivery-workflow-filestacks-integration-tools" rel="noopener noreferrer"&gt;file delivery&lt;/a&gt;&amp;nbsp;workflow guide, then check the&amp;nbsp;&lt;a href="https://www.filestack.com/docs/api/processing/" rel="noopener noreferrer"&gt;processing API docs&lt;/a&gt;&amp;nbsp;for the task list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Originally published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/free-cdn-for-images/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>filestack</category>
    </item>
    <item>
      <title>Build a Real Estate Listings App with Filestack</title>
      <dc:creator>IderaDevTools</dc:creator>
      <pubDate>Fri, 17 Jul 2026 05:12:08 +0000</pubDate>
      <link>https://dev.to/ideradevtools/build-a-real-estate-listings-app-with-filestack-4gih</link>
      <guid>https://dev.to/ideradevtools/build-a-real-estate-listings-app-with-filestack-4gih</guid>
      <description>&lt;p&gt;In real estate, the photos are the product. A buyer scrolling a results page won’t read your description if the cover image looks like it was shot on a flip phone. A detail page that ships a 4MB hero over LTE loses them before scroll.&lt;/p&gt;

&lt;p&gt;Doing images well usually means building a small pipeline: an upload endpoint that streams to S3, a worker pool for resizing, a CDN distribution, a queue for retries. That’s a meaningful chunk of engineering before you’ve shown a single listing. This guide walks through Horizon Pro, a real-estate marketplace built on&amp;nbsp;&lt;a href="https://www.filestack.com/" rel="noopener noreferrer"&gt;Filestack&lt;/a&gt;, which replaces that pipeline with a single SaaS layer.&lt;/p&gt;

&lt;h1&gt;
  
  
  What we’re building
&lt;/h1&gt;

&lt;p&gt;A user signs in, drags up to 10 photos onto a listing form, fills in price, beds, baths, and location, then publishes. The listing appears on the home grid with a cover thumbnail. On the detail page, the same handle powers a hero, a gallery strip, and a full-resolution lightbox. Three sizes, one upload, zero image-processing code.&lt;/p&gt;

&lt;h1&gt;
  
  
  Stac
&lt;/h1&gt;

&lt;p&gt;Filestack handles uploads (direct from the browser), storage (an S3 bucket you don’t provision), the CDN (edge POPs you don’t configure), and on-the-fly transformations driven by URL. Everything else is replaceable.&lt;/p&gt;

&lt;p&gt;Listings live in browser storage so the demo is self-contained. Drop in Postgres, Turso, or Supabase later by swapping the Zustand store for API calls. The upload and transformation layers stay the same.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 1: Get your Filestack API key
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://www.filestack.com/signup/" rel="noopener noreferrer"&gt;Sign up at filestack.com&lt;/a&gt;, grab the API key, and drop it in .env.local:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEXT_PUBLIC_FILESTACK_API_KEY=your_api_key_here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NEXT_PUBLIC_ prefix exposes the key to the browser, which is necessary because uploads go straight from the user’s machine to Filestack with no server hop. For production, lock the key down with Security Policies (allowed origins, MIME types, max size).&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 2: Build a custom drop zone
&lt;/h1&gt;

&lt;p&gt;Filestack ships a File Picker widget, but a marketplace usually wants the upload UI to feel native to its design system. We’ll talk to the File API directly with one fetch and build the drop zone from scratch. No SDK, no signed URL to pre-request. The byte path is user → Filestack → CDN; your backend isn’t in it.&lt;/p&gt;

&lt;h1&gt;
  
  
  The upload function
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// components/features/FilestackUploader.tsx

const FILESTACK_STORE_URL = “https://www.filestackapi.com/api/store/S3”;

async function uploadOne(file: File): Promise&amp;lt;IUploadedImage&amp;gt; {
  const apiKey = process.env.NEXT_PUBLIC_FILESTACK_API_KEY!;
  const url = `${FILESTACK_STORE_URL}?key=${apiKey}&amp;amp;filename=${encodeURIComponent(file.name)}`;

  const res = await fetch(url, {
    method: “POST”,
    headers: { “Content-Type”: file.type || “application/octet-stream” },
    body: file,
  });

  if (!res.ok) throw new Error(`Upload failed (${res.status})`);

  const data = await res.json();
  return {
    handle: data.url.split(”/”).pop() ?? “”,
    url: data.url,
    filename: data.filename,
    mimetype: data.type,
    size: data.size,
  };
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on the request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Path ends in /S3.&lt;/strong&gt;&amp;nbsp;That’s the storage backend. Filestack also supports azure, gcs, dropbox, rackspace. The default S3 bucket is fine if you don’t bring your own.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;API key goes in the query string.&lt;/strong&gt;&amp;nbsp;The File API is designed to be called from the browser; the key is rate-limited and origin-restricted once you turn on Security Policies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;filename in the query string&lt;/strong&gt;&amp;nbsp;so the file saves with a real name, not the handle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Content-Type falls back to application/octet-stream&lt;/strong&gt;&amp;nbsp;for files the browser can’t sniff (.heic, etc.). Filestack detects the real type from the bytes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Body is the File object.&lt;/strong&gt;&amp;nbsp;No FormData, no base64; fetch streams it as raw bytes.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h1&gt;
  
  
  What you get back
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
“url”: “https://cdn.filestackcontent.com/AbCdEfGh1234567”,
  “filename”: “kitchen.jpg”,
  “type”: “image/jpeg”,
  “size”: 481923,
  “key”: “qZx7..._kitchen.jpg”
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last segment of url is the&amp;nbsp;&lt;strong&gt;handle&lt;/strong&gt;. Save it. Everything downstream reads from it: thumbnails, hero images, format conversion, watermarks. Store the handle alongside the listing record and you’ve decoupled your data model from your image pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const handle = data.url.split(”/”).pop() ?? “”;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  The drop zone
&lt;/h1&gt;

&lt;p&gt;Two interaction modes to support: clicking to open the system picker, and dragging files onto a target. Both feed a FileList to the same handler. The cleanest way is a hidden that gets .click()ed when the drop zone is clicked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const inputRef = useRef&amp;lt;HTMLInputElement&amp;gt;(null);
const [isDragging, setIsDragging] = useState(false);

return (
  &amp;lt;div
    role=”button”
    tabIndex={0}
    onClick={() =&amp;gt; inputRef.current?.click()}
    onDragOver={(e) =&amp;gt; {
      e.preventDefault();          // required, otherwise drop won’t fire
      setIsDragging(true);
    }}
    onDragLeave={() =&amp;gt; setIsDragging(false)}
    onDrop={(e) =&amp;gt; {
      e.preventDefault();
      setIsDragging(false);
      void handleFiles(e.dataTransfer.files);
    }}
    onKeyDown={(e) =&amp;gt; {
      if (e.key === “Enter” || e.key === “ “) {
        e.preventDefault();
        inputRef.current?.click();
      }
    }}
    className={isDragging ? “drop-zone drop-zone--active” : “drop-zone”}
  &amp;gt;
    Click or drag photos here. Select multiple files at once.

    &amp;lt;input
      ref={inputRef}
      type=”file”
      accept=”image/*”
      multiple
      className=”hidden”
      onChange={(e) =&amp;gt; {
        if (e.target.files) void handleFiles(e.target.files);
        e.target.value = “”;       // allow re-selecting the same file
      }}
    /&amp;gt;
  &amp;lt;/div&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;e.preventDefault() in onDragOver is mandatory.&lt;/strong&gt;&amp;nbsp;Without it, the browser ignores the drop and opens the file instead. This is the most common reason a from-scratch drop zone “doesn’t work.”&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;role=”button” + tabIndex={0} + onKeyDown&lt;/strong&gt;&amp;nbsp;make the zone keyboard-accessible.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;e.target.value = “”&lt;/strong&gt;&amp;nbsp;after the change handler lets users re-upload the same file. File inputs only fire change on value change.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;accept=”image/*”&lt;/strong&gt;&amp;nbsp;filters the OS picker but doesn’t stop a user dragging in a PDF. We filter in handleFiles.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Handling the file list
&lt;/h1&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function handleFiles(incoming: FileList | File[]) {
const files = Array.from(incoming)
    .filter((f) =&amp;gt; f.type.startsWith(”image/”))
    .slice(0, maxFiles);

  if (files.length === 0) return;

  const results = await Promise.all(
    files.map(async (file) =&amp;gt; {
      try {
        return await uploadOne(file);
      } catch (err) {
        console.error(`Upload failed for ${file.name}:`, err);
        return null;
      }
    }),
  );

  const successful = results.filter((r): r is IUploadedImage =&amp;gt; r !== null);
  if (successful.length &amp;gt; 0) onUploadDone(successful);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things: the per-file try/catch lets the batch partial-succeed (one bad file doesn’t lose the rest), and Promise.all runs uploads in parallel. For more than 10 files at once you’d bound concurrency with something like&amp;nbsp;&lt;a href="https://www.npmjs.com/package/p-limit" rel="noopener noreferrer"&gt;p-limit&lt;/a&gt;, but a listing tops out at 10 photos.&lt;/p&gt;

&lt;h1&gt;
  
  
  Upload progress (optional)
&lt;/h1&gt;

&lt;p&gt;fetch doesn’t expose upload progress events. If progress bars matter, swap to XHR:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function uploadWithProgress(file: File, apiKey: string, onProgress: (pct: number) =&amp;gt; void) {

return new Promise&amp;lt;FilestackResponse&amp;gt;((resolve, reject) =&amp;gt; {
    const xhr = new XMLHttpRequest();
    const qs = new URLSearchParams({ key: apiKey, filename: file.name });

    xhr.open(”POST”, `https://www.filestackapi.com/api/store/S3?${qs}`);
    xhr.setRequestHeader(”Content-Type”, file.type || “application/octet-stream”);
    xhr.upload.onprogress = (evt) =&amp;gt; {
      if (evt.lengthComputable) onProgress(Math.round((evt.loaded / evt.total) * 100));
    };
    xhr.onload = () =&amp;gt; xhr.status &amp;lt; 300
      ? resolve(JSON.parse(xhr.responseText))
      : reject(new Error(String(xhr.status)));
    xhr.onerror = () =&amp;gt; reject(new Error(”Network error”));
    xhr.send(file);
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Horizon Pro a per-file spinner is enough. Reach for XHR when files are big enough that a percentage actually helps users decide whether to wait.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 3: Attach photos to a listing
&lt;/h1&gt;

&lt;p&gt;A listing is a small object (price, beds, baths, address) plus a list of images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// forms/ListingForm.tsx

const [images, setImages] = useState&amp;lt;IUploadedImage[]&amp;gt;([]);

{images.length &amp;lt; 10 &amp;amp;&amp;amp; (
  &amp;lt;FilestackUploader
    maxFiles={10 - images.length}
    onUploadDone={(uploaded) =&amp;gt; setImages((prev) =&amp;gt; [...prev, ...uploaded])}
  /&amp;gt;
)}

{images.map((img, idx) =&amp;gt; (
  &amp;lt;div key={img.handle} className=”relative aspect-square”&amp;gt;
    &amp;lt;img src={imagePresets.galleryThumb(img.handle)} alt={img.filename} /&amp;gt;
    {idx === 0 &amp;amp;&amp;amp; &amp;lt;span className=”badge”&amp;gt;Cover&amp;lt;/span&amp;gt;}
    &amp;lt;button onClick={() =&amp;gt; removeImage(idx)}&amp;gt;×&amp;lt;/button&amp;gt;
  &amp;lt;/div&amp;gt;
))}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Cap the uploader at 10 — images.length so users can’t exceed the limit across batches.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The first uploaded image is the cover, which is what shows up in search results.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Render previews from galleryThumb, not the original. The uploader gives you the handle, not the bytes, so every preview flows through the same CDN as production.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the form submits, save { handle, url, filename, mimetype, size, order } for each image alongside the listing. In our demo that’s a Zustand addListing action; in production it’s POST /listings.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 4: One handle, many sizes
&lt;/h1&gt;

&lt;p&gt;Every Filestack handle is a key into a Processing API that resizes, crops, converts, compresses, and filters on demand. The whole API is URL-driven. You build a URL, point an at it, Filestack runs the transform on first request, caches the result globally, and serves it from the edge on every subsequent hit.&lt;/p&gt;

&lt;p&gt;Build the URLs in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// lib/filestack.ts
const CDN_BASE = “https://cdn.filestackcontent.com”;

export function getTransformedUrl(handle: string, opts: ITransformOptions = {}): string {
  const tasks: string[] = [];

  if (opts.width || opts.height) {
    const parts: string[] = [];
    if (opts.width)  parts.push(`width:${opts.width}`);
    if (opts.height) parts.push(`height:${opts.height}`);
    if (opts.fit)    parts.push(`fit:${opts.fit}`);
    tasks.push(`resize=${parts.join(”,”)}`);
  }
  if (opts.quality) tasks.push(`quality=value:${opts.quality}`);
  if (opts.format)  tasks.push(`output=format:${opts.format}`);

  return tasks.length === 0
    ? `${CDN_BASE}/${handle}`
    : `${CDN_BASE}/${tasks.join(”/”)}/${handle}`;
}

export const imagePresets = {
  thumbnail:    (h: string) =&amp;gt; getTransformedUrl(h, { width: 400,  height: 270, fit: “crop”, format: “webp”, quality: 80 }),
  card:         (h: string) =&amp;gt; getTransformedUrl(h, { width: 600,  height: 400, fit: “crop”, format: “webp”, quality: 85 }),
  hero:         (h: string) =&amp;gt; getTransformedUrl(h, { width: 1200, height: 800, fit: “crop”, format: “webp”, quality: 90 }),
  galleryThumb: (h: string) =&amp;gt; getTransformedUrl(h, { width: 200,  height: 150, fit: “crop”, format: “webp”, quality: 75 }),
  full:         (h: string) =&amp;gt; `${CDN_BASE}/${h}`,
};
Every surface uses the same handle through a different preset:
&amp;lt;img src={imagePresets.card(image.handle)} /&amp;gt;          // homepage grid
&amp;lt;img src={imagePresets.hero(image.handle)} /&amp;gt;          // detail hero
&amp;lt;img src={imagePresets.galleryThumb(image.handle)} /&amp;gt;  // gallery strip
&amp;lt;img src={imagePresets.full(image.handle)} /&amp;gt;          // lightbox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A card thumbnail’s URL looks like:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cdn.filestackcontent.com/resize=width:600,height:400,fit:crop/output=format:webp/quality=value:85/AbCdEfGh1234567" rel="noopener noreferrer"&gt;https://cdn.filestackcontent.com/resize=width:600,height:400,fit:crop/output=format:webp/quality=value:85/AbCdEfGh1234567&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Transformations chain left-to-right. Adding a new responsive breakpoint is one preset and zero infrastructure.&lt;/p&gt;

&lt;h1&gt;
  
  
  Step 5: Search and filter
&lt;/h1&gt;

&lt;p&gt;Listings in client state plus URL-derived image URLs means search is pure computation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// components/features/SearchResults.tsx
const params = useSearchParams();
const listings = useListingStore((s) =&amp;gt; s.listings);

const results = useMemo(() =&amp;gt; {
  return listings.filter((l) =&amp;gt; {
    if (city &amp;amp;&amp;amp; !l.city.toLowerCase().includes(city)) return false;
    if (minPrice &amp;amp;&amp;amp; l.price &amp;lt; minPrice) return false;
    if (minBeds &amp;amp;&amp;amp; l.bedrooms &amp;lt; minBeds) return false;
    if (type &amp;amp;&amp;amp; l.propertyType !== type) return false;
    return true;
  });
}, [listings, params]);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;URL params drive the sidebar via useRouter().replace(), so a filtered view is shareable and bookmarkable. When you move to a database, this filter becomes a WHERE clause; the UI doesn’t change.&lt;/p&gt;

&lt;h1&gt;
  
  
  Beyond listings
&lt;/h1&gt;

&lt;p&gt;The same handle pattern extends to most of the surrounding product:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SurfaceFilestack feature&lt;/strong&gt;Agent headshotsfit:crop + rounded_corners=radius:Floor plan PDFsFilestack&amp;nbsp;&lt;a href="https://www.filestack.com/products/document-viewer/" rel="noopener noreferrer"&gt;Document Viewer&lt;/a&gt;Watermarked previewsChain watermark= over the cover imageVideo walkthroughsVideo API over Filestack’s CDNAuto-tag rooms, moderationFilestack Intelligence&lt;/p&gt;

&lt;h1&gt;
  
  
  Production checklist
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Move listings out of localStorage into a real database&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set NEXT_PUBLIC_FILESTACK_API_KEY in your hosting environment&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Configure Filestack Security Policies (origin lock, MIME image/*, ~10MB cap)&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Add a moderation hook via Filestack Intelligence&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Consider&amp;nbsp;&lt;a href="https://www.filestack.com/products/workflows/" rel="noopener noreferrer"&gt;Workflows&lt;/a&gt;&amp;nbsp;for chained processing&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  Further reading
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;TopicLink&lt;/strong&gt;File API&lt;a href="https://www.filestack.com/docs/api/file/" rel="noopener noreferrer"&gt;Reference&lt;/a&gt;All transformations&lt;a href="https://www.filestack.com/docs/api/processing/" rel="noopener noreferrer"&gt;Processing API&lt;/a&gt;File Picker widget&lt;a href="https://www.filestack.com/docs/uploads/pickers/" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;Security&lt;a href="https://www.filestack.com/docs/security/" rel="noopener noreferrer"&gt;Policies&lt;/a&gt;AI moderation, tagging&lt;a href="https://www.filestack.com/products/artificial-intelligence/" rel="noopener noreferrer"&gt;Intelligence&lt;/a&gt;SDKs&lt;a href="https://www.filestack.com/docs/concepts/sdks/" rel="noopener noreferrer"&gt;Filestack SDKs&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  Final thoughts
&lt;/h1&gt;

&lt;p&gt;Most real-estate apps reinvent an image pipeline they don’t need to build: an upload route, an S3 bucket, a worker for resizing, a CDN distribution. Filestack collapses that into a handle and a URL convention.&lt;/p&gt;

&lt;p&gt;Build the preset library once, route every through it, and adding a new size is one line. The same pattern works for the next image-heavy app you build (e-commerce, CMS, social, SaaS), so the abstraction travels with you.&lt;/p&gt;

&lt;p&gt;Try the live demo&amp;nbsp;&lt;a href="https://filestack-use-cases-fs-realestate.vercel.app/" rel="noopener noreferrer"&gt;here&lt;/a&gt;&amp;nbsp;or&amp;nbsp;&lt;a href="https://github.com/Fileschool/filestack-use-cases/tree/main/apps/fs-realestate" rel="noopener noreferrer"&gt;grab the source on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;Originally published on the&lt;/em&gt;&lt;/strong&gt;&amp;nbsp;&lt;a href="https://blog.filestack.com/build-real-estate-listings-app-filestack/" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;em&gt;Filestack blog&lt;/em&gt;&lt;/strong&gt;&lt;/a&gt;&lt;strong&gt;&lt;em&gt;.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
