Fix Heroku R14 "Memory Quota Exceeded" for a Django App

Fix Heroku R14 Memory quota exceeded and R15 errors for Django: measure memory per Gunicorn worker, set WEB_CONCURRENCY to fit the dyno, recycle workers with max-requests, find leaks in views and Celery, and choose a dyno size that matches.

Problem statement

Heroku logs fill with:

heroku[web.1]: Process running mem=612M(119.6%)
heroku[web.1]: Error R14 (Memory quota exceeded)

Response times climb, because once a dyno passes its memory quota it starts swapping to disk. If usage reaches twice the quota, Heroku kills the dyno with R15 (Memory quota vastly exceeded), and users see H10 or H13 errors during the restart. A Django dyno runs several Gunicorn worker processes, each a full copy of your app, so total memory is roughly workers times memory per worker, plus growth over time.

How the quota is exceededDyno quotae.g. 512 MBGunicorn workerseach loads DjangoUsage growsrequests and leaksOver quotaR14, then swapTotal memory is worker count times per-worker size, and both can be tuned.
  1. Check the dyno size and memory: heroku ps and the Metrics tab (memory usage, swap).
  2. Set worker count to what fits: heroku config:set WEB_CONCURRENCY=2 on a 512 MB dyno.
  3. Recycle workers: add --max-requests 1000 --max-requests-jitter 100 to Gunicorn.
  4. Find memory-heavy views: large querysets loaded at once, big file uploads in memory, unbounded caches.
  5. Move Celery or other processes to their own dynos, or step up the dyno size.

Step-by-step solution

1. Measure memory per worker

Enable log-runtime-metrics or watch the Metrics tab. Then estimate per-worker memory with one worker:

heroku config:set WEB_CONCURRENCY=1
heroku logs --tail | grep "mem="

A typical Django app uses 120–250 MB per worker after warming up. Divide the dyno quota by that number and leave headroom: a 512 MB dyno fits two workers of 180 MB, not four.

2. Set WEB_CONCURRENCY explicitly

The Heroku Python buildpack sets a default WEB_CONCURRENCY based on dyno size, and Gunicorn reads it. Defaults assume a small app; set it from measurement:

heroku config:set WEB_CONCURRENCY=2
# Procfile
web: gunicorn config.wsgi --max-requests 1000 --max-requests-jitter 100 --timeout 25

If you need more concurrency for I/O-bound views, add threads (--threads 4) instead of processes. Threads share memory.

workers x 180 MB each (illustrative)1 worker180 MB2 workersfits3 workersR144 workersswapping0 MB512 MB800 MBFit worker count to the dyno instead of the defaultdjango-deployment.com
Measure your own per-worker size; it varies a lot between apps.

3. Recycle workers to contain growth

Python processes rarely return memory to the OS, and some libraries leak slowly. --max-requests restarts each worker after a number of requests, and jitter spreads the restarts:

web: gunicorn config.wsgi --max-requests 1000 --max-requests-jitter 100

If memory keeps rising between restarts, find the leak rather than lowering the number further.

4. Find memory-hungry code

Common causes in Django:

  • Iterating over a large queryset loads every row into memory. Use .iterator(chunk_size=2000) or paginate.
  • Building a large CSV or JSON response in memory. Stream it with StreamingHttpResponse.
  • FILE_UPLOAD_MAX_MEMORY_SIZE set high, keeping uploads in RAM. Let large files spool to disk.
  • Module-level caches (dicts that grow per request) or lru_cache without a size limit.
  • DEBUG = True in production, which stores every SQL query in memory.

5. Separate process types

A Procfile that starts Celery inside the web dyno (with honcho or &) makes both share one quota. Give each its own process type:

web: gunicorn config.wsgi --max-requests 1000 --max-requests-jitter 100
worker: celery -A config worker --concurrency 2 --max-tasks-per-child 200

Celery's --max-tasks-per-child plays the same role as Gunicorn's --max-requests.

pattern and fixMemory patternCause and fixhigh right after boottoo many workers;lower WEB_CONCURRENCYslow steady climbleak or fragmentation;max-requests, find itspikes on one endpointbig queryset or export;iterate or streamworker and web togethershared dyno; separate process typesThe shape of the memory graph tells you which fix to trydjango-deployment.com
Heroku Metrics shows the graph; match its shape to the table.

Explanation

Each Heroku dyno has a fixed memory quota by size. Gunicorn's pre-fork model creates independent processes, each importing Django, your apps, and their dependencies, so memory multiplies with worker count. On top of that baseline, each request allocates objects; CPython's allocator keeps freed memory for reuse, so a worker's resident size tends to stay at its high-water mark. R14 is Heroku's warning that the dyno passed its quota and started swapping; R15 is the hard limit.

Edge cases / notes

  • preload_app. --preload loads the app before forking, sharing some memory between workers through copy-on-write. Gains are modest in CPython because reference counting touches shared pages.
  • Pillow and NumPy. Image processing and data libraries can allocate hundreds of megabytes per request. Offload heavy work to worker dynos.
  • Performance dynos. Larger dynos have more memory per unit cost, which can be cheaper than many small ones for memory-heavy apps.

Next steps

FAQ

Does R14 crash my app?

Not immediately. The dyno swaps to disk and slows down. At double the quota, R15 kills it.

How many Gunicorn workers fit on a basic dyno?

Usually two on a 512 MB dyno for a typical Django app. Measure your per-worker memory to be sure.

Will max-requests hide a memory leak?

It contains the damage by restarting workers periodically. Find the leak if memory still climbs quickly.

Should I upgrade the dyno instead?

If per-worker memory is reasonable and you need more concurrency, yes. If memory grows without bound, a bigger dyno only delays R14.

Keep going