Fix a Slow First Request After a Django Deploy

Fix slow first requests after a Django deploy or restart: measure cold versus warm, find heavy imports with -X importtime, precompile bytecode, preload, warm workers, and avoid cold starts.

Problem statement

Right after a deploy or restart, the first request to each page takes several seconds, sometimes long enough to hit a proxy or load balancer timeout. Subsequent requests are fast. Monitoring shows a latency spike on every deploy, and users who happen to arrive first see a spinner or a 504.

The first request in each worker process pays one-time costs: importing modules that were not needed at startup, compiling Python bytecode, loading and compiling templates, building URL resolvers, opening database connections, and filling empty caches. Each Gunicorn worker pays them separately. This guide measures where the time goes and moves that work out of user requests.

Cold versus warm workerWorker startsminimal importsFirst requestimports, templatesConnectionsDB, cache, APIsWarm workerfast responsesWarm each worker before it receives real traffic, or move the one-time work to startup.
  1. Measure: time the first and second request to the same URL on a freshly started worker.
  2. Profile imports with python -X importtime -c "import config.wsgi" and move heavy imports out of request paths or into startup.
  3. Precompile bytecode during the build (python -m compileall) so workers do not compile .py files at runtime.
  4. Use Gunicorn --preload (or warm-up in wsgi.py) so shared work happens once before forking.
  5. Send warm-up requests to new instances before they receive traffic, via readiness checks that hit real pages.
  6. On platforms that scale to zero, keep a minimum number of instances running.

Step-by-step solution

1. Measure the cold penalty

sudo systemctl restart gunicorn
sleep 2
for i in 1 2 3; do
  curl -s -o /dev/null -w "request $i: %{time_total}s\n" -H "Host: example.com" \
    --unix-socket /run/gunicorn/gunicorn.sock http://localhost/products/
done
the cold penaltyFirst request per worker~2.4 sSecond request~180 msSteady state~120 ms0 ms1000 ms2000 msmsIllustrative numbers; measure your own with curl -wdjango-deployment.com
Every worker pays the cold penalty once, so four workers means four slow requests.

With several workers, consecutive requests may land on different cold workers, so run more requests than you have workers and look at the pattern. A large gap between the first requests and the rest confirms a warm-up cost.

2. Find heavy imports

DJANGO_SETTINGS_MODULE=config.settings.production \
  python -X importtime -c "import config.wsgi" 2> /tmp/imports.txt
sort -t'|' -k2 -n /tmp/imports.txt | tail -20

The second column is cumulative import time in microseconds. Then import the modules the first request needs:

python -X importtime -c "
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
django.setup()
import config.urls
" 2> /tmp/urls-imports.txt
sort -t'|' -k2 -n /tmp/urls-imports.txt | tail -20

Typical heavy imports are data science libraries (pandas, numpy, scikit-learn), PDF and image libraries, cloud SDKs, and large generated API clients. If only a few views need them, import them inside those functions. If most requests need them, import them at startup (step 4) so no user waits.

URL resolution is lazy: Django imports every module referenced by urls.py on the first request that resolves a URL. Large URL trees pull in many view modules at that moment.

3. Precompile bytecode

Python compiles .py files to bytecode on first import and caches it in __pycache__. If the cache directory is not writable (read-only filesystems, or images built with PYTHONDONTWRITEBYTECODE=1), every new process compiles everything again. Compile during the build:

RUN python -m compileall -q /app /usr/local/lib/python3.12/site-packages

For non-container deploys, run python -m compileall -q . in the release directory before starting Gunicorn.

4. Do shared work once with --preload

gunicorn config.wsgi:application --preload --workers 4 --bind unix:/run/gunicorn/gunicorn.sock
import once, fork manyWithout --preloadeach worker imports Django✕first request per worker is slow✓HUP reload picks up new codeWith --preload✓master imports once, then forks✓workers start warm, share memoryno DB connections before forkWith --preload, new code needs a full restartdjango-deployment.com
Connections opened at import time get shared across workers and break.

With --preload, the master imports the application (and whatever wsgi.py imports) before forking workers, so each worker starts with those modules already loaded, and memory pages are shared copy-on-write. You can add explicit warm-up to wsgi.py:

# config/wsgi.py
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
application = get_wsgi_application()

# Warm-up: import URLconf and view modules before workers fork
from django.urls import get_resolver  # noqa: E402

get_resolver().url_patterns

Do not open database connections or network clients in the master: forked workers would share the same sockets. Django opens database connections lazily per worker, which is safe. With --preload, a code change requires a full restart instead of a HUP reload.

5. Warm templates and caches

With DEBUG = False, Django uses the cached template loader, so each template is compiled once per process, on first use. That is fast for most templates; complex template trees can take tens of milliseconds per page type. Warm-up requests (step 6) cover this naturally.

Empty caches after a deploy are a separate cold start: if you change cache key prefixes or KEY_PREFIX/VERSION on every release, every page is recomputed at once. Change cache versions only when the cached data format changes, and precompute expensive fragments in a background job.

6. Warm new instances before traffic

Point readiness checks, or a post-start step in your deploy, at pages that exercise the application, not only a trivial health view:

# after starting the new release, before switching the proxy or load balancer
for path in / /products/ /accounts/login/; do
  for i in $(seq 1 8); do
    curl -s -o /dev/null -H "Host: example.com" --unix-socket /run/gunicorn/gunicorn.sock "http://localhost$path"
  done
done
warm it before users hit it1Start the new instancenot yet in the pool2Request key URLshome, list, detail3Templates and caches loadedimports done4Readiness passeshealth endpoint5Add to the load balancerusers see warm workersHit each worker, or use --preload so one warm-up covers alldjango-deployment.com
A warm-up script can live in the deploy, right after the restart.

Send enough requests to reach every worker. In Kubernetes, a startupProbe or readinessProbe hitting a warm-up endpoint, together with maxUnavailable: 0, keeps traffic on old pods until new ones are warm.

7. Avoid platform cold starts

On Cloud Run, App Engine, Azure Container Apps, and similar platforms, instances scaled to zero must start a container, boot Python, and import Django before serving. Set a minimum instance count for user-facing services, and keep images small so they pull quickly.

Verification check:

Restart or deploy, then repeat the measurement from step 1 immediately after the warm-up step. The first user-facing request should now be close to the warm timing, and deploy-time latency spikes should flatten in your monitoring.

Explanation

A Python process only does work when asked: modules are imported on first use, templates compiled on first render, URL patterns loaded on first resolve, connections opened on first query. Gunicorn multiplies this by the number of workers, because each is a separate process. The fixes all move the same work earlier: into the build (bytecode), into the master before forking (preload), or into synthetic requests before real ones (warm-up).

Edge cases / notes

Worker recycling

--max-requests restarts workers periodically to contain memory growth; each new worker is cold. Use --max-requests-jitter so workers do not all restart at once, and combine with --preload so restarted workers start warm.

Slow first request to external services

TLS handshakes and DNS lookups to payment or email APIs happen on first use per process. Reusing HTTP sessions (a module-level requests.Session created lazily per process) avoids paying that on every request.

When to automate this

Make the warm-up step part of the deploy script or readiness probe, and track "first request after deploy" latency as a metric. A regression in that number usually means someone added a heavy import to a common path.

Next steps

FAQ

Why is the first request after restarting Django so slow?

Each worker imports modules, compiles templates, loads URL patterns, and opens connections on its first request. Those one-time costs make the first response slow.

Does Gunicorn --preload make startup faster?

It moves application import into the master process, so workers start with modules already loaded. It speeds up worker readiness and saves memory.

Should I precompile Python bytecode in Docker images?

Yes, especially if the runtime filesystem is read-only or bytecode writing is disabled. Run python -m compileall during the build.

How do I warm up Django after a deploy?

Send requests to key pages on the new instances before switching traffic to them, enough to reach every worker.

Keep going