Flask supports async views when the async extra is installed, but its WSGI request still occupies one worker for the request-response cycle. Awaiting independent I/O can shorten one response; adding async def does not raise the number of simultaneous requests that the existing worker pool can serve. A task created inside an async view is not a durable background job. Once the view completes, its event loop may stop and unfinished child tasks are cancelled. For a promised permit notification or export, record intent transactionally and let a separate worker process it. This is also a context boundary: a worker cannot reuse request, g, or a Flask-SQLAlchemy session from the original view.
Flask Async Views and Durable Background Work
Working case
After approving permit 62, a route calls asyncio.create_task to send a notice and immediately returns 200. In tests with a fast fake sender, the notice appears. Under a slower provider, the view finishes and the task is cancelled; case 62 remains approved but no notice is sent. Replacing the function with async def does not repair the delivery contract. The corrected approval transaction stores outbox event 93 with the state change. A queue notification may wake a worker, but a periodic sweep also finds unsent events if that wake-up is lost. The worker opens its own app context, claims event 93 under a lease, sends with a stable event key, and records delivery state.
Implementation boundary
from flask import current_app
def deliver_outbox_event(app_factory, event_id: int) -> None:
application = app_factory()
with application.app_context():
outbox = current_app.extensions["permit_outbox"]
claimed = outbox.claim_with_lease(event_id)
if claimed is None:
return
try:
receipt = outbox.sender.send(claimed.payload, key=claimed.event_key)
except TimeoutError:
outbox.mark_retryable(event_id)
return
outbox.mark_delivered(event_id, receipt)Use async views only when the awaited dependencies are actually asynchronous and their concurrency benefits one request. Check extension decorators for async compatibility. Keep synchronous database calls out of an event loop unless an appropriate bridge or worker thread owns them; merely writing await around sync code does nothing. For durable effects, insert an outbox row in the approval transaction. After commit, enqueue its ID as a wake-up hint, then sweep pending IDs independently. Worker input should be immutable identifiers and serialized command data, never request proxies or ORM instances. Open a fresh app context for Flask-SQLAlchemy access, claim with a lease, and acknowledge after a provider result. A timeout may mean accepted-but-unacknowledged delivery, so reconcile or retry under the same event identity.
Cost and boundaries
An async view consumes a WSGI worker while it runs, although concurrent awaited I/O may reduce its latency. The outbox adds a database write and storage per committed event; sweep scans and lease updates consume database capacity, and a worker pool needs its own operational budget. Sending large payloads or blocking libraries from the event loop reduces the benefit of async. At-least-once processing can duplicate an external effect when acknowledgment is lost, so a provider idempotency key or reconciliation endpoint matters. Track oldest pending age, claim lease expiry, attempt count, provider response class, request worker occupancy, and queue lag. A 200 response proves the approval commit, not final delivery.
Failure trace
Use a slow fake sender to show that a spawned task is cancelled after an async view returns; do not treat a fast local pass as proof of background execution. Roll back an approval and verify no outbox event exists. Commit the case and kill the process before queue submission; restart the sweep and confirm it finds event 93. Crash a worker after claim, let its lease expire, then reclaim the same ID. Timeout after the provider may have accepted a send and ensure retries use that same identity. Stop all workers and confirm monitoring shows pending age rising. Run a worker with no request context and verify it succeeds using its own app context and fresh session.
Verification
- The worker receives an event ID and opens its own app context.
- A database row survives a lost queue wake-up.
- An ambiguous provider result retains the same event identity.
Practice drill
Build notification delivery for a permit approval. In the approval transaction, persist the case change and event 93. Enqueue event 93 only as a hint after commit. Give the worker a bounded lease, attempt budget, and durable delivered or review-needed state. Add a scheduled sweep that scans 47 pending events per pass. Test rollback, post-commit process failure, worker restart, lost provider acknowledgment, and an invalid permanent recipient. Separately write an async view that awaits two independent noncritical lookups, measure its latency and WSGI worker occupancy, and compare it with the synchronous version. Document which behavior is request latency optimization and which behavior is durable delivery.
Decision note
Await only request-scoped I/O; use a committed outbox and independent worker for promised effects.
Common Mistakes
- Calling asyncio.create_task and returning before promised work completes.
- Assuming async def increases WSGI worker concurrency.
- Passing request proxies or live ORM models to a worker.
Related lessons
Flask Context and Service Boundaries; Flask Request Context and Object Authorization; Flask-SQLAlchemy Session and Query Lifetime; Flask Command Validation and Transaction Replay; Python Flask ETag responses: preserve cache validators across conditional reads; Background Workflow Reliability; FastAPI Lifespan, Background Work, and Delivery State.
Apply and check
Build Project: Flask permit service boundaries and review Web Development: Flask context and service boundaries quiz.
