Calling Async Libraries from Flask Views¶
Flask 2.0 added async def views, and it is tempting to read that as "Flask is async now". It is not: Flask remains a WSGI framework, and each async view is run by asgiref's async_to_sync on an event loop created for that call. Measured with Flask 3.1 and asgiref 3.12, 20 requests to an async view ran on 20 distinct event loops, and an async view cost 401 µs of framework overhead per request in the test client against 75 µs for a sync view. The consequence that bites is resource sharing: an httpx.AsyncClient created once and reused across requests returned 200, then RuntimeError: Event loop is closed, then 200 again — its pooled connection belonged to a loop that no longer existed. This guide shows what does work for calling async libraries from Flask.
Prerequisites¶
- Python 3.11+,
pip install "flask[async]" httpx. - Why loop-bound objects break, from creating futures with loop.create_future.
- Sync-to-async bridges, from calling async code from synchronous code safely.
1. Understand what an async Flask view is¶
import asyncio
from flask import Flask
app = Flask(__name__)
loops = []
@app.get("/loop")
async def which_loop():
loops.append(asyncio.get_running_loop()) # keep references so ids are not reused
return "ok"
client = app.test_client()
for _ in range(20):
client.get("/loop")
print(len({id(l) for l in loops})) # 20 — a fresh loop per request
The WSGI server calls the view synchronously; Flask wraps the coroutine in async_to_sync, which runs it to completion on a new event loop and returns the result. Concurrency inside one view works — asyncio.gather over three API calls runs them concurrently — but nothing lives longer than the request. There is no application-wide loop, so there is nowhere for a connection pool, a background task or a cache of futures to live.
Verify: count distinct loop objects across requests in your deployment; one per request means anything loop-bound must also be per request.
2. Do not share async clients across requests¶
Creating an async client at import time and reusing it is the most common Flask-async bug:
shared = httpx.AsyncClient(timeout=2) # created once — WRONG under Flask
@app.get("/proxy")
async def proxy():
r = await shared.get(UPSTREAM) # 200, then "Event loop is closed", then 200…
return r.text
Measured across three requests: 200, RuntimeError: Event loop is closed, 200. The first request opened a keep-alive connection on loop 1; the second request found that pooled connection, whose transport belonged to the closed loop 1, and failed; the failed connection was discarded, so the third request opened a fresh one and succeeded. In production that pattern looks like intermittent errors that correlate with nothing.
The fix inside an async view is a client per request, closed at the end:
@app.get("/proxy")
async def proxy():
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(UPSTREAM)
return r.text
That loses connection reuse across requests — each request pays TCP and TLS setup — which is the real cost of async views in Flask. The pooling arithmetic is in sizing async connection pools for throughput.
Verify: a load test against the proxy view shows no Event loop is closed errors.
3. Prefer the sync client in sync code¶
If the library offers a synchronous client, use it from Flask. A sync httpx.Client created once per worker process pools connections correctly across requests, because WSGI requests on a thread are ordinary sync calls:
import httpx
sync_client = httpx.Client(timeout=5, limits=httpx.Limits(max_connections=20))
@app.get("/proxy")
def proxy():
return sync_client.get(UPSTREAM).text
@app.get("/fan-out")
def fan_out():
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=3) as pool:
a, b, c = pool.map(sync_client.get, [URL_A, URL_B, URL_C])
return {"a": a.status_code, "b": b.status_code, "c": c.status_code}
httpx.Client is safe to share across threads. A small thread pool gives concurrency within a request without an event loop. Use async views only when you need a library that is only async, and accept the per-request setup.
Verify: the upstream sees connections reused across requests, and there are no loop errors.
4. Run a persistent loop on a background thread for async-only libraries¶
When the library is async-only and per-request setup is too expensive — an async database driver, a gRPC channel, a websocket client — keep one event loop alive in a background thread for the whole process, create the async resources on it, and submit work from views:
import asyncio
import threading
class LoopThread:
def __init__(self) -> None:
self.loop = asyncio.new_event_loop()
threading.Thread(target=self.loop.run_forever, name="async-bridge", daemon=True).start()
def run(self, coro, timeout: float = 10.0):
return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout)
bridge = LoopThread()
async_client = bridge.run(make_async_client()) # created ON the bridge loop, once
@app.get("/proxy")
def proxy(): # a SYNC view
return bridge.run(async_client.get(UPSTREAM)).text
Every coroutine runs on the same long-lived loop, so pooled connections stay valid. Views are plain sync functions, and multiple WSGI threads can submit concurrently: the bridge loop interleaves their coroutines. This is the same structure as running an event loop in a background thread; one bridge per worker process, created after the process starts (not before a fork).
Verify: under concurrent load from several WSGI threads, the async client's pool is reused and no request sees a loop error.
5. Consider moving the service to an ASGI framework¶
If most of a Flask app's views end up async, the per-request loop is overhead with no benefit: 401 µs against 75 µs per request in the test client, plus losing connection reuse. At that point an ASGI framework — Quart (Flask's API on ASGI), Starlette or FastAPI — gives one long-lived loop per worker, where shared clients and background tasks work normally. Quart in particular is designed so that most Flask code ports with import changes. The ASGI side of the story is in ASGI Servers & Frameworks.
Verify: count async views and async-only dependencies; if they are the majority, plan the migration rather than adding bridges.
Verification¶
Flask and async libraries coexist correctly when:
- No async client or pool is created at import time and reused across async views.
- Sync clients are used wherever the library offers them.
- Async-only resources that need pooling live on one persistent loop in a background thread.
- Load tests show no
Event loop is closedorattached to a different looperrors.
Diagnostic Hook: count RuntimeError exceptions whose message mentions the event loop, per view, and alert on any. They are always this bug and always intermittent — rising and falling with how often pooled connections get reused — so a counter catches what log scanning tends to miss.
Pitfalls & edge cases¶
- Module-level async clients. They work on the first request and fail later ones.
- Background tasks from async views.
asyncio.create_taskinside a view is cancelled or destroyed when the per-request loop closes. - Creating the bridge loop before forking workers. Gunicorn's pre-fork model copies the thread object but not the running thread; create bridges in a post-fork hook.
- Blocking the bridge loop. Every view shares it; one slow synchronous call inside a coroutine stalls all of them.
Frequently Asked Questions¶
Does Flask run async views on an event loop?
Yes, but a new one for each call. Flask is WSGI, and async views are run with asgiref's async_to_sync. In testing, 20 requests ran on 20 distinct event loops.
Why do I get 'Event loop is closed' with httpx in Flask?
The AsyncClient was created once and reused, so its pooled connections belong to the event loop of an earlier request, which has since closed. Create the client per request, use httpx.Client, or run a persistent loop in a background thread.
Can I share an aiohttp ClientSession across Flask requests?
Not in async views, for the same reason: the session is bound to the loop it was created on. Keep it on a persistent background loop and submit coroutines with run_coroutine_threadsafe from sync views.
Should I use async views in Flask for performance?
Usually not. They add per-request event loop overhead and prevent connection reuse. Use them for occasional calls to async-only libraries, or move to an ASGI framework such as Quart if most views are async.
Related¶
- Hybrid Concurrency Models — up to the topic overview.
- Mixing sync and async views in Django — the same problem in a framework with an ASGI mode.
- Concurrent Execution & Worker Patterns — the section overview.