Async, gevent & Quart

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Flask 3.1.3, Werkzeug 3.1.9, asgiref 3.12.1, gevent 26.9.0, Gunicorn 26.2.0, Hypercorn 0.18.0, Quart 0.23.1 and Python 3.13, checked on 2026-10-07. Quart 0.23 needs Python 3.13 or newer; Flask itself runs on older versions. Flask 3.2 is not released yet, and nothing on this page depends on it. Every example was executed with those versions: most through Flask’s and Quart’s test clients, and the ones that need a real server (gevent, Gunicorn, Hypercorn) as separate scripts against a loopback port, with a local fake standing in for every external service. The question this page answers is the one every Flask developer meets sooner or later: the application waits on other services (a metadata API, a database, a mail server), so how do I wait without wasting a worker? Flask has four answers, and they are different in kind: async def views inside a WSGI worker, gevent greenlets, an ASGI server in front of a WSGI app, and a different framework, Quart. The running example is the Bookshelf, which looks up book metadata from a slow external catalogue service.

For the Python side of the story (threads, processes, asyncio, the GIL) see Concurrency & Async in the Python Reference. For how an async-first framework is built from the start, compare FastAPI.

Four Ways to Wait

A WSGI server hands a request to a worker (a process or a thread), and that worker is busy until the response is returned. If the view spends 90 percent of its time waiting for a remote service, 90 percent of the worker is idle, and the only way to serve more requests at once is to add workers. Each approach on this page attacks that waste in a different place.

Four rows on a time axis: a sync worker handles request A, B and C one after the other; an async view in Flask overlaps awaits inside one request but still queues requests; gevent greenlets overlap the waits of different requests; Quart tasks overlap on one long-lived event loop and can also hold a websocket
Figure 1. The same three requests that each wait on I/O, in four execution models. Only gevent and Quart overlap the waits of different requests.
Model What waits cheaply What you write Server

Sync Flask

Nothing: the worker blocks

Plain def views

Any WSGI server; scale with processes and threads

async def in Flask

Awaits inside one request

async def and await

The same WSGI server

gevent

Any patched blocking call, in many requests at once

Plain def views

Gunicorn or uWSGI gevent worker, or gevent’s own WSGI server

Quart

Awaits in many requests, plus websockets and background tasks

async def and await everywhere

An ASGI server such as Hypercorn

Flask itself is a WSGI application (PEP 3333), the interface described in Introduction & Architecture. WSGI has no way to express "this request is suspended, run another one". Everything below either works around that limit (rows two and three), changes the server interface (WSGI to ASGI), or replaces the framework (Quart).

async def Views, Handlers and Hooks

Since Flask 2.0, routes, error handlers, before_request, after_request and teardown functions may be coroutine functions, provided Flask is installed with the async extra. The extra pulls in asgiref, the library Flask uses to bridge the sync and async worlds.

pip install "flask[async]"

The quotes keep shells such as zsh from reading the square brackets as a glob pattern. Without the extra, the first request to an async def view fails with a RuntimeError that says to install Flask with the async extra.

A view becomes asynchronous when it is declared with async def; nothing else changes. The example below enriches a book with metadata from three catalogue lookups that run at the same time, which is the situation where await earns its keep: independent I/O inside one request. The lookup is a local fake (asyncio.sleep stands in for a network call).

import asyncio
import threading
import time

from flask import Flask, jsonify, request

app = Flask(__name__)


async def catalogue_lookup(source: str, isbn: str) -> dict:
    await asyncio.sleep(0.2)  # local fake of a remote metadata service
    return {"source": source, "isbn": isbn, "pages": 320}


@app.get("/books/<isbn>/enriched")
async def enriched(isbn: str):
    results = await asyncio.gather(
        catalogue_lookup("openlibrary-fake", isbn),
        catalogue_lookup("worldcat-fake", isbn),
        catalogue_lookup("library-fake", isbn),
    )
    in_main = threading.current_thread() is threading.main_thread()
    return jsonify(sources=[r["source"] for r in results], path=request.path, ran_in_main_thread=in_main)


client = app.test_client()
start = time.perf_counter()
response = client.get("/books/9781234567890/enriched")
elapsed = time.perf_counter() - start
print(response.status_code, response.get_json())
assert elapsed < 0.5  # three 0.2 s lookups overlapped: about 0.2 s, not 0.6 s

The view finishes in about 0.2 seconds instead of 0.6, because asyncio.gather overlaps the three waits. The extra output field shows something about the mechanism that the next section explains: the coroutine did not run in the thread that handled the request, yet request.path was readable inside it.

Class-based views work the same way: View.dispatch_request and every HTTP method handler of a MethodView may be coroutines (see Class-Based Views). Hooks, error handlers and teardown functions are wrapped with the same machinery, so a single application can mix def and async def freely.

import asyncio

from flask import Flask, g, jsonify
from flask.views import MethodView

app = Flask(__name__)
events = []


@app.before_request
async def start_timer():
    await asyncio.sleep(0)  # a real app might await a session store here
    g.audit = ["before_request"]


@app.after_request
async def add_header(response):
    response.headers["X-Hooks"] = ",".join(g.audit)
    return response


@app.teardown_request
async def finish(exc):
    events.append("teardown")


@app.errorhandler(404)
async def missing(error):
    await asyncio.sleep(0)
    return jsonify(error="not found"), 404


class ShelfAPI(MethodView):
    async def get(self, shelf: str):
        await asyncio.sleep(0)
        g.audit.append("view")
        return jsonify(shelf=shelf)


app.add_url_rule("/shelves/<shelf>", view_func=ShelfAPI.as_view("shelf"))

client = app.test_client()
ok = client.get("/shelves/sci-fi")
print(ok.status_code, ok.headers["X-Hooks"], events)
print(client.get("/nope").get_json())

Signal receivers can be coroutines too, through Blinker’s async support; see Signals.

How It Works and Why It Is Not Faster

When a request reaches an async def view, Flask calls Flask.ensure_sync(), which returns plain functions unchanged and wraps coroutine functions with Flask.async_to_sync(). That method is a thin layer over asgiref.sync.async_to_sync: it starts an event loop in a thread, runs the coroutine to completion there, and the worker waits for the result, then the response is returned like any other. The official page states the consequence directly: Flask uses one worker for one request/response cycle, and each request ties up one worker even for async views. The number of requests the application can handle at one time stays the same.

sequenceDiagram participant S as WSGI worker participant F as Flask dispatch participant L as Event loop in a helper thread S->>F: request arrives F->>F: ensure_sync wraps the async def view F->>L: asgiref async_to_sync starts the coroutine Note over S,F: the worker blocks and waits L->>L: await and gather L-->>F: return value F-->>S: response, the loop is finished

The practical rules that follow:

  • Async is not inherently faster. It helps when one request has several independent I/O waits (the gather above). It does not help CPU-bound work, and it does not let one worker serve a second request while the first one waits. Each request also pays for starting and stopping an event loop, which is why the Flask documentation says Flask’s async support is less performant than async-first frameworks.

  • The event loop is private to one request. Nothing created in one request, such as a connection pool bound to a loop, may be reused in the next one: the loop it belongs to is gone. Create such objects per request, or use a sync client.

  • Per-request context still works. request, g and current_app are context variables, and Flask copies the context into the loop, so they are available inside a coroutine.

  • CPU or blocking calls inside the coroutine block that request’s loop, though not other requests, because other requests have their own workers. If a sync-only library (a legacy SDK) must be called from an async def view, asyncio.to_thread() moves the call off the loop so that it can overlap with the other awaits.

The measurement below makes the first point concrete. The same view (a 0.3 second await) is served by Gunicorn with one sync worker, and ten clients call it at once.

gunicorn -w 1 -b 127.0.0.1:8000 aapp:app    # aapp.py: an async def view awaiting asyncio.sleep(0.3)
10 requests answered in 3.05 s       # about 10 x 0.3 s: the requests were served one after the other

The next example calls a sync-only function from an async view with asyncio.to_thread, so that two blocking calls overlap.

import asyncio
import time

from flask import Flask, jsonify

app = Flask(__name__)


def legacy_isbn_check(isbn: str) -> bool:
    time.sleep(0.2)  # a blocking SDK call
    return isbn.startswith("978")


@app.get("/check")
async def check():
    first, second = await asyncio.gather(
        asyncio.to_thread(legacy_isbn_check, "9781234567890"),
        asyncio.to_thread(legacy_isbn_check, "1234567890123"),
    )
    return jsonify(valid=[first, second])


client = app.test_client()
start = time.perf_counter()
print(client.get("/check").get_json())
assert time.perf_counter() - start < 0.35  # the two blocking calls overlapped

No Background Tasks

The event loop stops when the async function returns, and every task that is still pending is cancelled. This is the pitfall that catches people coming from async frameworks: asyncio.create_task() in a view does not give you "fire and forget".

import asyncio
import time

from flask import Flask, jsonify

app = Flask(__name__)
finished = []


async def refresh_cover_cache():
    await asyncio.sleep(0.2)
    finished.append("covers")  # never reached under Flask's per-request loop


@app.post("/covers/refresh")
async def refresh():
    asyncio.create_task(refresh_cover_cache())
    return jsonify(accepted=True), 202


client = app.test_client()
print(client.post("/covers/refresh").status_code)
time.sleep(0.5)
print(finished)  # []: the task was cancelled when the view returned
assert finished == []

Three ways out, in the order the official documentation prefers them:

  1. Hand the work to a task queue. Celery or RQ run the job in another process, survive restarts and can retry; see Background Jobs, Celery & Email.

  2. Serve Flask over ASGI with WsgiToAsgi. The adapter keeps one event loop alive for the whole process, so tasks outlive the request (see Flask over ASGI). They are still lost if the process stops.

  3. Move to Quart, which has add_background_task and a long-lived loop (see Quart).

Plain threads are the book-era answer (the Flask Cookbook runs mail sending and similar work in a Thread, mentions async / await and Quart in passing, and recommends Celery for anything serious). Threads work with both sync and async views, but a thread has no application or request context, so the data it needs must be passed in explicitly, and the work is lost when the process restarts. The sketch below runs a fresh event loop in a daemon thread.

import asyncio
import threading

from flask import Flask, jsonify

app = Flask(__name__)
finished = threading.Event()


async def refresh_cover_cache(isbns: list[str]):
    await asyncio.sleep(0.1)
    finished.set()


def run_in_thread(isbns: list[str]):
    asyncio.run(refresh_cover_cache(isbns))


@app.post("/covers/refresh")
def refresh():
    # pass plain data, never the request or current_app proxies
    threading.Thread(target=run_in_thread, args=(["9781234567890"],), daemon=True).start()
    return jsonify(accepted=True), 202


client = app.test_client()
print(client.post("/covers/refresh").status_code)
print(finished.wait(timeout=2))

The book idiom of sending mail from a threading.Thread started inside a view fails when the thread touches request, current_app or g, because those proxies are bound to the context of the thread that created them. Either pass the exact values the thread needs (the approach above), or push a context in the thread with app.app_context() or the copy_current_request_context decorator shown in the gevent section.

ensure_sync, Decorators and Extensions

ensure_sync is the seam between the two worlds, and it is also where async support is lost. A decorator written for sync views typically ends with return view(*args, **kwargs). Applied to an async def view, that call returns a coroutine object that nobody awaits, and Flask answers with a 500 (Python also warns that the coroutine "was never awaited"). The fix, which the official page recommends for extension authors, is to call the view through current_app.ensure_sync.

from functools import wraps

from flask import Flask, current_app

app = Flask(__name__)


def naive(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        return view(*args, **kwargs)  # returns an un-awaited coroutine

    return wrapper


def async_aware(view):
    @wraps(view)
    def wrapper(*args, **kwargs):
        # works for def and async def views alike
        return current_app.ensure_sync(view)(*args, **kwargs)

    return wrapper


@app.get("/naive")
@naive
async def naive_view():
    return "unreachable"


@app.get("/aware")
@async_aware
async def aware_view():
    return "ok"


@app.get("/sync")
@async_aware
def sync_view():
    return "ok too"


app.testing = False  # let Flask turn the error into a 500 response
client = app.test_client()
print(client.get("/naive").status_code, client.get("/aware").status_code, client.get("/sync").status_code)

Extensions that were written before async support arrived do not expect async views. Their decorators will not await the function, and the helper functions they offer are not awaitable and will block the loop if called from a coroutine. Check the extension’s changelog for async support. For example, Flask-Login’s login_required calls the view through current_app.ensure_sync (confirmed in the installed 0.6.3 source), so it works on an async def view, while extensions whose decorators do not call ensure_sync need a feature request or a thin wrapper of your own.

from flask import Flask
from flask_login import LoginManager, login_required

app = Flask(__name__)
app.config["SECRET_KEY"] = __import__("secrets").token_hex(16)
login_manager = LoginManager(app)


@login_manager.user_loader
def load_user(user_id):
    return None  # nobody is signed in in this demo


@app.get("/shelf")
@login_required
async def shelf():
    return "my shelf"


app.testing = False
print(app.test_client().get("/shelf").status_code)  # 401: login_required ran, and the async view was not reached

Flask supports only asyncio. To use another loop implementation, override Flask.ensure_sync or Flask.async_to_sync, as the gevent section shows. Hook ordering and decorator patterns are in Hooks & View Decorators.

gevent

gevent is the older route to the same goal, and Flask has worked with it since long before asyncio. It patches the standard library so that blocking calls (sockets, time.sleep, locks, DNS) yield to other greenlets, lightweight coroutines implemented by the greenlet library, instead of blocking the thread. The code stays plain def, with no await anywhere. The Flask documentation calls it a reliable way to handle numerous, long-lived, concurrent connections, and notes that it relies on gevent’s and greenlet’s low-level manipulation of the interpreter. It is not the same thing as async / await or the ASGI specification.

Monkey patching

The patching must be applied as early as possible, before anything imports socket, ssl, threading or time. Putting it at the very top of the entry module (or the top package’s __init__.py) is the documented approach. If a module that holds a reference to an unpatched function is imported earlier, that reference keeps blocking.

# norun  (patches the whole process: run as a script, see the loopback test below)
import gevent.monkey

gevent.monkey.patch_all()

import time

import gevent
from flask import Flask, copy_current_request_context, jsonify, request

app = Flask(__name__)
audit = []


def slow_lookup(isbn):
    time.sleep(0.5)  # patched: other greenlets run while this one sleeps
    return {"isbn": isbn}


@app.get("/lookup/<isbn>")
def lookup(isbn):
    return jsonify(slow_lookup(isbn))


@app.post("/books")
def create_book():
    @copy_current_request_context
    def audit_later():
        gevent.sleep(0.1)
        audit.append(request.path)  # request is still available here

    gevent.spawn(audit_later)
    return jsonify(created=True), 201

Served by gevent’s own WSGIServer on a loopback port and called by twenty greenlets at once, the /lookup view answered all twenty requests in 0.54 seconds, where one sync worker needs ten seconds for twenty half-second waits. The audit entry appeared after the response, with request.path still readable.

gevent.spawn() starts a concurrent task from inside a view. A spawned function runs after the view returns, so the request context is gone by then. The documentation says to decorate it with stream_with_context() or copy_current_request_context() if it needs request or other context globals, and also says to prefer passing the exact data needed instead of using either decorator. copy_current_request_context copies the request context (including the session) when the function is decorated and pushes the copy when it is called; it raises a RuntimeError outside a request. The example above uses it to show the mechanism, and the same decorator fixes the book-era "send mail from a thread" idiom.

Gunicorn gevent workers

In production, do not run gevent’s WSGIServer directly. The Flask documentation prefers Gunicorn or uWSGI with a gevent worker, which provides a more configurable and production-tested server. With Gunicorn it is one option, -k gevent (--worker-class gevent), and the application module does not need to call patch_all itself: the worker patches the process early when it boots (a plain time.sleep view overlapped under -k gevent in a test with no patching in the application module).

pip install gunicorn gevent
gunicorn -k gevent -w 1 -b 127.0.0.1:8000 'hello:create_app()'

With one gevent worker and a view that sleeps 0.5 seconds, twenty simultaneous requests were answered in 0.54 seconds. The default -w 1 is usually too few for production: use one gevent worker per CPU core, and let each worker multiplex its connections. The number of simultaneous connections per worker is worker_connections (default 1000). Greenlets only help with I/O waits: a CPU-bound view still occupies its whole process. greenlet 1.0 or newer is required, and PyPy needs 7.3.7 or newer. Process supervision, reverse proxies and the other WSGI servers belong to the deployment page of this section.

Pitfalls

  • Everything that blocks must be patched or gevent-aware. A C extension or database driver that blocks inside C code (a driver without a gevent-compatible mode) stalls every greenlet in the worker. PostgreSQL’s psycopg2 needs the psycogreen helper to cooperate; check each driver’s documentation.

  • Thread-locals and globals behave differently. After patch_all threading.local becomes greenlet-local, which is what Flask’s contextvars-based proxies need. Code that assumes a worker handles one request at a time (module-level state mutated per request) breaks.

  • gevent does not mix with Flask’s built-in async support. Patching does not interact well with the per-request loop that async_to_sync starts. If both are needed, override Flask.async_to_sync to run coroutines on a loop that lives in a greenlet, as the official page shows.

# norun  (patches the whole process: needs Python 3.13 for asyncio.EventLoop)
import gevent.monkey

gevent.monkey.patch_all()

import asyncio

import gevent
from flask import Flask, request

loop = asyncio.EventLoop()
gevent.spawn(loop.run_forever)


class GeventFlask(Flask):
    def async_to_sync(self, func):
        def run(*args, **kwargs):
            coro = func(*args, **kwargs)
            future = asyncio.run_coroutine_threadsafe(coro, loop)
            return future.result()

        return run


app = GeventFlask(__name__)


@app.get("/")
async def greet():
    await asyncio.sleep(0.2)
    return f"Hello, {request.args.get('name', 'World')}!"

Ten simultaneous requests to this app, served by gevent’s WSGIServer, took 0.23 seconds. asyncio.EventLoop exists from Python 3.13; on older versions use asyncio.new_event_loop(). The official page also covers libuv as an alternative gevent loop (set gevent.config.loop = "libuv" before patch_all), and recommends gevent’s own libuv support rather than uvloop. Treat these combinations as limited: the page itself warns they may have limitations.

eventlet is retired

eventlet used to be the other popular green-thread library, and several books and tutorials (including the Flask-SocketIO ones) show eventlet.monkey_patch() and the Gunicorn -k eventlet worker. The Flask documentation’s eventlet page now says plainly that eventlet is no longer maintained and to use gevent instead. When you meet eventlet code, replace eventlet.monkey_patch() with gevent.monkey.patch_all() and -k eventlet with -k gevent.

Flask over ASGI with WsgiToAsgi

ASGI is the asynchronous successor to WSGI: a server calls the application with an event loop running, so one process can hold thousands of open connections, long-lived requests and websockets. A WSGI application such as Flask cannot be called by an ASGI server directly, so a WSGI-to-ASGI adapter sits between them. The Flask documentation recommends asgiref’s WsgiToAsgi because it integrates with the event loop used for Flask’s own async support.

# norun  (served by Hypercorn, see the command below)
import asyncio

from asgiref.wsgi import WsgiToAsgi
from flask import Flask, jsonify

app = Flask(__name__)
finished = []


async def refresh_cover_cache():
    await asyncio.sleep(0.3)
    finished.append("covers")


@app.post("/covers/refresh")
async def refresh():
    asyncio.create_task(refresh_cover_cache())
    return jsonify(accepted=True), 202


@app.get("/covers/status")
def status():
    return jsonify(finished=finished)


asgi_app = WsgiToAsgi(app)

Source: Flask: ASGI

pip install "flask[async]" hypercorn
hypercorn module:asgi_app

Source: Flask: ASGI

Running the app above under Hypercorn, the POST returned 202 and a GET /covers/status call 0.6 seconds later listed covers: the background task survived the request, which the per-request loop of a normal WSGI deployment would have cancelled. Hypercorn logs a warning that the ASGI framework (the adapter) does not support the lifespan protocol, which is harmless; it only means no startup or shutdown hooks reach Flask.

What the adapter does and does not change:

  • WSGI calls are still synchronous. A plain def view runs in a thread from the adapter’s executor, so it does not block the loop, but it also does not become more concurrent than a thread pool. An async def view runs on the long-lived loop, so its awaits overlap with those of other requests.

  • There are no websockets and no ASGI-native features (lifespan, server push): Flask has no API for them. The adapter is a deployment bridge, not a path to real-time features.

  • A background task is bound to the process. It is lost on restart, and nothing retries it, so a task queue remains the answer for work that must not be lost.

This keeps the code unchanged while gaining background tasks and an async-native server, and it is the smallest step toward an ASGI deployment. When the project wants websockets, async extensions or async database drivers across the whole request path, the honest move is Quart.

Quart

Quart is a reimplementation of the Flask API on top of ASGI, hosted by Pallets. It builds on Flask’s Sans-IO classes and asyncio, so most Flask code reads the same, but everything that touches I/O is a coroutine. The Flask documentation points to it for codebases that are mostly async: Quart handles many concurrent requests, long-running requests and websockets without extra worker processes or threads.

When to switch

Stay on Flask when Move to Quart when

The app is mostly sync (SQLAlchemy sessions, blocking SDKs) and scales by adding workers

Most work is I/O against async libraries (httpx, asyncpg, redis.asyncio)

You depend on Flask extensions with no async story

You need websockets, server-sent events with many clients, or long-lived connections

Background work can go to Celery or RQ

You want await everywhere without a per-request loop and an ensure_sync seam

gevent already solves the concurrency problem for you

You want an ASGI server and its lifespan protocol (startup and shutdown hooks)

If the application is mostly sync, or the team prefers plain def, gevent often delivers the concurrency with far less rewriting. If the application is greenfield and async-first, the other mainstream choice is FastAPI. The official documentation puts it this way: deciding between gevent, Quart or something else is ultimately about the specific needs of the project.

The migration recipe

The Quart project documents Flask migration as a short mechanical recipe, and the table below is the recipe in order.

Step Change

1

Install quart and change from flask import Flask to from quart import Quart. Names such as request, g, jsonify, Blueprint, url_for and render_template have the same names in quart.

2

Declare every route, hook and error handler as async def.

3

await the request-data accessors that read the body: await request.get_json(), await request.data, await request.form, await request.files.

4

await template rendering: await render_template(...), await render_template_string(...).

5

Make tests async: await client.get(...), await response.get_json().

6

Replace the WSGI server with an ASGI one (Hypercorn, or quart run in development).

7

Replace extensions that block, or check whether Quart-Flask-Patch makes a Flask extension work. Look for Quart-native extensions first.

A Flask view and its Quart equivalent show the whole change.

# Flask
from flask import Flask, jsonify, request

flask_app = Flask(__name__)
shelves = {}


@flask_app.post("/shelves")
def create_shelf():
    data = request.get_json()
    shelves[data["name"]] = []
    return jsonify(data), 201


flask_client = flask_app.test_client()
print(flask_client.post("/shelves", json={"name": "to-read"}).status_code)
# Quart: the same view with async def and await
import asyncio

from quart import Quart, jsonify, request

quart_app = Quart(__name__)
shelves = {}


@quart_app.post("/shelves")
async def create_shelf():
    data = await request.get_json()
    shelves[data["name"]] = []
    return jsonify(data), 201


async def main():
    client = quart_app.test_client()
    response = await client.post("/shelves", json={"name": "to-read"})
    print(response.status_code, await response.get_json())


asyncio.run(main())

What changes

Beyond the await keywords, a Quart application differs from a Flask one in these ways.

  • Lifespan and background tasks. before_serving, while_serving and after_serving hooks run once at server start and stop (create a connection pool in before_serving, close it in after_serving), and app.add_background_task() runs a coroutine on the application’s long-lived loop, which is exactly what Flask’s per-request loop cannot do.

  • Websockets. @app.websocket("/path") declares a websocket endpoint, and a websocket global (like request) gives await websocket.receive() and await websocket.send(...). Websocket depth (authentication, scaling, rooms) is outside this page; Flask’s own options (flask-sock, Flask-SocketIO) are in the streaming page of this section.

  • Sync views still work. A plain def view in Quart runs in a thread pool (through sync_to_async), so it does not block the loop.

  • A blocking call inside async def blocks everything. There is only one loop. Offload the call with asyncio.to_thread() or loop.run_in_executor().

  • The server is ASGI. Development: quart --app hello run --debug (the CLI offers run, routes and shell) or app.run(). Production: Hypercorn (hypercorn hello:app; -w sets workers and -b the bind address), which supports HTTP/1, HTTP/2 and websockets.

  • Test client. Awaitable, with async with client.websocket("/path") as ws for websockets, and app.test_app() as an async context manager to run the startup and shutdown hooks.

  • Extensions. Flask extensions are written for WSGI and for blocking calls. Some work through the Quart-Flask-Patch package, which patches Quart to behave as Flask (it was part of Quart until 0.19.0), and many popular concerns (authentication, schemas, CORS) have Quart-native equivalents. Check each extension.

The next example shows these Quart features together: a startup hook, a background task, awaited request data, a rendered template and a websocket, all driven by the test client.

import asyncio

from quart import Quart, jsonify, render_template_string, request, websocket

app = Quart(__name__)
books = {}
indexed = []


@app.before_serving
async def startup():
    app.config["READY"] = True  # open pools and clients here; close them in after_serving


async def index_book(isbn):
    await asyncio.sleep(0.1)
    indexed.append(isbn)


@app.post("/books")
async def create_book():
    data = await request.get_json()
    books[data["isbn"]] = data
    app.add_background_task(index_book, data["isbn"])  # runs after the response, on the app's loop
    return jsonify(data), 201


@app.post("/form")
async def form():
    return jsonify(dict(await request.form))


@app.get("/page")
async def page():
    return await render_template_string("<h1>{{ title }}</h1>", title="Bookshelf")


@app.websocket("/ws/echo")
async def echo():
    while True:
        message = await websocket.receive()
        await websocket.send(f"echo: {message}")


async def main():
    async with app.test_app() as test_app:
        client = test_app.test_client()
        response = await client.post("/books", json={"isbn": "9781234567890", "title": "Dune"})
        print(response.status_code, await response.get_json())
        print(await (await client.post("/form", form={"shelf": "to-read"})).get_json())
        print(await (await client.get("/page")).get_data(as_text=True))
        async with client.websocket("/ws/echo") as ws:
            await ws.send("hello")
            print(await ws.receive())
    print(indexed)


asyncio.run(main())

The final example proves the blocking pitfall. Five concurrent requests each call a blocking function that takes 0.2 seconds. In the async def view that calls it directly the event loop is stalled and the requests are served one after the other (about 1.0 s); with asyncio.to_thread, or as a plain def view, they overlap (about 0.2 s).

import asyncio
import time

from quart import Quart

app = Quart(__name__)


def read_isbn_registry():
    time.sleep(0.2)  # a blocking driver or file read
    return "registry"


@app.get("/blocking")
async def blocking():
    return read_isbn_registry()  # stalls the whole event loop


@app.get("/offloaded")
async def offloaded():
    return await asyncio.to_thread(read_isbn_registry)


@app.get("/plain")
def plain():
    return read_isbn_registry()  # a plain def runs in a thread pool


async def timed(client, path):
    start = time.perf_counter()
    await asyncio.gather(*(client.get(path) for _ in range(5)))
    return round(time.perf_counter() - start, 1)


async def main():
    client = app.test_client()
    for path in ("/blocking", "/offloaded", "/plain"):
        print(path, await timed(client, path), "s for 5 concurrent requests")


asyncio.run(main())

On a live Quart server the same view overlaps for real: ten simultaneous requests to an async def view that awaits asyncio.sleep(0.3) were answered by Hypercorn in 0.33 seconds, against 3.05 seconds for the same view on one sync Gunicorn worker.

pip install quart hypercorn
quart --app hello run --debug      # development server with the reloader; never expose --debug publicly
hypercorn hello:app --bind 127.0.0.1:8000 --workers 2

Production notes carry over from Flask. Put a reverse proxy in front, bind to loopback, never run the development server or the debug mode in production, and keep secrets in the environment or in a secret store. The Flask side of the same advice is in Security Considerations.

Choosing Between Them

Need Best fit Why Cost

Several independent waits in one request

async def view with gather

No new server, no new dependency beyond flask[async]

A loop per request; no extra capacity

Many slow clients on an existing sync code base

gevent worker

Plain def code, I/O overlap across requests

Monkey patching, driver compatibility

Background task or async-native server with Flask unchanged

WsgiToAsgi + Hypercorn

One long-lived loop

No websockets; tasks are not durable

Websockets, long-lived streams, async libraries throughout

Quart

Native ASGI, lifespan, background tasks

Rewrite with await; extension compatibility

Durable background work

Celery or RQ

Survives restarts, retries

An extra service

Where the Books Are Outdated

  • The Flask Cookbook chapter on additional tips shows background work with threads and mentions async / await and Quart. Treat that as the starting point: today Flask has first-class async def support (2.0 and later), its limits are documented (per-request loop, no background tasks), and a thread started from a view still has no context.

  • Book-era recipes that send mail from a threading.Thread fail when the thread reads request or current_app; pass the data explicitly or use copy_current_request_context / app.app_context(), or better a task queue.

  • Examples and tutorials that use eventlet (eventlet.monkey_patch(), -k eventlet) are obsolete because eventlet is no longer maintained; use gevent.

  • Claims that async views are impossible in Flask predate Flask 2.0 (2021); claims that they make Flask faster are wrong.