2026-01-05

Don't Let Large Uploads Crash Your Server: The 10-Line Flask Fix

Python, Flask, Security, Reliability · Dorian Sotpyrc

Here’s the production death spiral I’ve seen more than once: a real user drags in a 300MB video, their mobile connection stalls mid-stream, and while your workers are pinned waiting on slow request bodies, a bot finds /upload and starts hammering it with “uploads” that never finish. CPU climbs, memory spikes, the request queue backs up, and your upstream starts serving 502s while you scramble.

A Flask API under pressure from oversized and stalled uploads
Large request bodies can pin sync workers, amplify memory/CPU costs during multipart parsing, and turn uploads into a reliability + security problem.
TL;DR

Uploads are a high-cost attack surface. The fastest production-grade fix is to cap request size and fail closed with a clean 413.

  • Set a hard upper bound with MAX_CONTENT_LENGTH (no “we’ll validate after we read it”).
  • Return a predictable 413 JSON payload via RequestEntityTooLarge handler.
  • Match limits at the edge (NGINX/ALB) so oversized bodies never reach your workers.

How servers die from “just an upload”

Uploads fail differently in production than they do on your laptop because they combine three nasty effects:

  • Worker exhaustion: in a typical sync WSGI setup, each slow upload can occupy a worker for a long time. Enough slow clients and you’ve effectively self-DOSed.
  • Memory/CPU pressure: multipart/form-data parsing and buffering isn’t free. Big bodies can trigger expensive work before your business logic ever runs.
  • Slowloris-style pressure: an attacker doesn’t need high bandwidth—just lots of long-lived connections that drip bytes to keep your app busy.

What Flask is doing under the hood (and why it hurts)

In Flask, requests arrive through the WSGI server. If you accept arbitrarily large bodies, you’re letting the request stream reach the app boundary before you’ve enforced any contract. Then multipart/form-data parsing kicks in, and that’s where costs add up: CPU to parse boundaries, memory/disk behavior in file handling, and a worker tied up while the client slowly finishes (or never finishes).

This is why “we’ll validate after we read it” is the wrong posture for uploads. For reliability and security, you want a hard cap and a clean failure path that’s fail-closed (reject, don’t truncate).

The 10-line fix: hard cap + clean 413, fail-closed

This drop-in pattern does three things: sets a strict maximum upload size, fails fast with an explicit 413 Request Entity Too Large, and avoids any silent truncation. It also gives clients a stable error payload instead of a worker crash or a generic 502.

PYTHON

from flask import Flask, jsonify
from werkzeug.exceptions import RequestEntityTooLarge

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 25 * 1024 * 1024  # 25MB hard cap

@app.errorhandler(RequestEntityTooLarge)
def too_large(_):
    return jsonify(error="upload_too_large", max_bytes=app.config["MAX_CONTENT_LENGTH"]), 413
    

Why this works: MAX_CONTENT_LENGTH enforces a strict upper bound on incoming request size, and Werkzeug raises RequestEntityTooLarge when the limit is exceeded. Your handler turns that into a predictable API response, so clients can react and your app stays stable.

Where to put it (so it actually runs in prod)

  • App factory: set MAX_CONTENT_LENGTH immediately after app = Flask(__name__), and register the error handler during app creation (before blueprints start handling requests).
  • Single-file app.py: put it right after you instantiate app. Don’t tuck it behind a conditional that might not execute in Gunicorn/uWSGI startup.

If you need different limits per endpoint, don’t “raise the global ceiling.” Prefer splitting large uploads onto a separate service/path with its own limits and infrastructure (or use direct-to-object-storage uploads).

Test it fast: prove you won’t OOM or hang

I validate this change with one simple expectation: an oversized upload returns 413 quickly and consistently, even when repeated. You’re looking for controlled rejection (413 with your JSON) rather than slow timeouts, 502s, or workers piling up.

BASH

# Make a 30MB file and POST it to your endpoint (should return 413 quickly)
dd if=/dev/zero of=big.bin bs=1m count=30
curl -i -F "[email protected]" http://localhost:5000/upload
    

Also check logs: you want a clear, low-noise signal that requests were rejected due to size, not an unhandled exception or cascading timeouts.

Best-practice add-ons (after the quick win)

The 10-line fix stabilizes your Flask layer immediately. To harden the whole path, add these defense-in-depth controls:

  • Reverse proxy body cap: enforce a matching (or slightly lower) limit at NGINX/ALB so oversized bodies don’t even reach your app workers.
  • Body/read timeouts: configure upstream timeouts to cut off stalled uploads (reduces slowloris-style pressure).
  • Gunicorn timeouts + worker model: set reasonable timeouts and choose a worker type appropriate for your traffic pattern.
  • Stream/bypass the app for big files: for large media, use streaming to disk or (better) direct-to-object-storage uploads (e.g., S3 multipart), then send your API only the metadata.
  • Rate limiting + auth: protect /upload like a paid endpoint—because it costs you real resources.
Checklist before you ship uploads

If you do nothing else, make sure these are true in production:

  • Flask enforces MAX_CONTENT_LENGTH and returns a clean 413 JSON response (fail-closed, no truncation).
  • Your edge/proxy rejects oversized bodies (client_max_body_size or equivalent) before they reach app workers.
  • Stalled uploads are cut off with body/read timeouts (reduces slowloris-style pressure).
  • Gunicorn/uWSGI timeouts are sensible for your traffic pattern and worker model.
  • Large media uploads bypass the app (direct-to-object-storage) or are streamed safely, and /upload is rate limited + authenticated.

Related PLEX reading

References & further reading