Picking the right worker model, or the wrong expectations of async, decides how a Flask app behaves under load.
Flask is a WSGI framework: one request occupies one worker (a process, thread or greenlet) from start to finish. Concurrency therefore comes from the server:
- sync workers: N processes, N simultaneous requests; simple and robust for CPU-light apps
- threads (
gunicorn --threads 8): more concurrent I/O waits per process; your code and libraries must be thread-safe - gevent/eventlet workers: thousands of greenlets with monkey-patched I/O, good for many slow connections, but every library must cooperate
`async def` views are supported since Flask 2.0 after pip install "flask[async]" (which installs asgiref). Each async view runs in an event loop on the worker thread handling the request. Inside one view you can await asyncio.gather(...) to run several I/O calls concurrently, which is the real benefit. But the worker is still busy until the view finishes, so async views do not let one worker serve more requests at once, and they add some overhead.
Things that do not fit Flask's model: WebSockets, server-sent events with many clients, and long polling at scale. Use Quart (an ASGI reimplementation of the Flask API), FastAPI or Starlette for those, or a separate service.
Background work (emails, reports) should not run in the request at all: hand it to a task queue such as Celery or RQ, or a simple thread pool for small cases, and return immediately.
Profile first: most Flask apps are limited by the database, so connection pool size and query count matter more than the worker model.
Example
import asyncio, time
from flask import Flask
app = Flask(__name__) # requires: pip install "flask[async]"
async def fetch(name, delay):
await asyncio.sleep(delay) # stands in for an HTTP or DB call
return name
@app.get("/dashboard")
async def dashboard():
start = time.perf_counter()
users, orders, stats = await asyncio.gather(
fetch("users", 0.2), fetch("orders", 0.2), fetch("stats", 0.2))
return {"parts": [users, orders, stats],
"took_s": round(time.perf_counter() - start, 1)}
print(app.test_client().get("/dashboard").json)
# {'parts': ['users', 'orders', 'stats'], 'took_s': 0.2} (not 0.6)Remember
- Each request holds a worker until it finishes; concurrency comes from workers, threads or greenlets
- async views help run several awaits concurrently inside one request
- Use Quart/FastAPI for WebSockets and huge numbers of open connections; use task queues for background work
Watch out for
- Converting views to async def expecting higher throughput per worker.