Django Async Views vs Background Tasks
Choose between Django async views and background tasks using request lifetime, transactions, retries, state, and operational cost.
A Django async view runs awaited work inside the HTTP request lifecycle and must return the result in that response. A queued background task transfers ownership after enqueueing succeeds. Use an async view for concurrent I/O that the current response needs. Use a background task when work must outlive the connection, then choose queue durability and retry semantics that provide that guarantee.
That boundary matters more than whether a function is declared with async
def. A coroutine can still hold a request open for minutes. A queue can still
execute an ordinary synchronous function. "Async" describes concurrency;
"background" describes ownership and lifetime.
The framework behavior in this guide was verified against Django 6.0 documentation on July 31, 2026.
The short decision table
| Question | Async view | Background task |
|---|---|---|
| Does the current response need the result? | Yes | Usually no |
| What happens on a client disconnect? | Cancellation must be acceptable | Work can outlive the request, subject to queue durability |
| Can it start only after a database commit? | Possible, but awkward inside the response | Natural with transaction.on_commit() |
| Does it need independent retries? | Build request-level retry handling | Use worker retry policy plus idempotency |
| Does the user check the result later? | Return it now or stream it | Persist domain status and expose polling |
| What infrastructure does it need? | ASGI and an async-compatible request stack for full benefit | Queue/backend, worker, monitoring, and recovery |
Use a Django async view when all of these are true:
- The response needs the result.
- Most of the wait is async-compatible network I/O.
- The work should stop if the request goes away.
- The latency fits your HTTP timeout budget.
Use a background task when any of these are true:
- The work should continue after the response.
- It can take long enough to threaten an HTTP timeout.
- It needs retries, scheduling, workload isolation, or rate control.
- A person or API client will inspect its state later.
If you already know the work belongs in a queue, the Django background-task comparison covers Django 6 Tasks, Django Q2, and Celery. This guide answers the earlier question: should there be a queued job at all? Djass-generated projects include the Q2 worker and deployment wiring for that side of the boundary.
Why Django async views are not background tasks
Django detects an async def view and awaits it as part of request
handling.
Under ASGI, an async-compatible middleware stack lets the process serve many
connections without assigning one Python thread to each waiting connection.
That is useful for slow streaming, long-polling, and concurrent calls to
external services.
The request still owns the work. Django's async
documentation
states that a client disconnect during a long-lived request raises
asyncio.CancelledError in the view. You can catch it for cleanup, but you
should re-raise it. A view is therefore the wrong owner for an export, email
batch, media conversion, or repository build that must finish after the
browser closes.
Under WSGI, Django runs an async view in a one-off event loop. That permits concurrent async calls inside the request but does not provide the full async-stack benefits of ASGI. Deployment mode changes how the request runs, not who owns the work.
Apply the six-boundary test
The following test is a practical placement framework. Answer each question before choosing syntax or a queue package.
1. Who needs the result?
If the current response cannot be correct without the result, keep the work in the request. A pricing page that must combine three fast service lookups can run those calls concurrently and return one response.
If the result is a report, ZIP archive, delivered email, imported dataset, or later webhook side effect, create durable application state and return early. The response can provide a status URL rather than waiting for completion.
2. What should happen on disconnect?
Request-owned work must tolerate cancellation when the client disappears. Handle cleanup and do not assume cancellation can reverse an external side effect that another service already accepted.
Job-owned work should continue independently once durably enqueued. A browser refresh, network change, or API timeout should not abandon a requested export or leave repository generation without recoverable state. Put that work behind a worker and make its state queryable.
3. Where is the transaction boundary?
Dispatching before a database transaction commits creates a race: the worker may query a row that is still invisible, or it may process state that later rolls back. Django documents this exact risk in its Tasks transaction guidance.
Register the enqueue operation with
transaction.on_commit()
when the job depends on newly committed state:
from functools import partial
from django.db import transaction
from django_q.tasks import async_task
with transaction.atomic():
export = Export.objects.create(status="queued")
transaction.on_commit(
partial(
async_task,
"reports.tasks.build_export",
export_id=export.id,
group="Build Export",
)
)
on_commit() executes immediately when no transaction is open. Inside an
atomic block, Django runs it after the outer transaction commits and discards
it if the transaction rolls back.
This prevents a pre-commit visibility race, but it does not make the database commit and broker enqueue atomic. The data can commit and the enqueue callback can still fail. For critical work, persist an outbox record in the same transaction, then dispatch or reconcile that record separately.
4. How should failure and repetition work?
An HTTP request can retry an upstream read while its deadline permits. It should not quietly replay a non-idempotent side effect after returning an uncertain response.
A worker can retry independently, but "at least once" behavior means the same
job may run more than once. Use an idempotency key, a unique constraint, a
state transition guard, or an external provider's idempotency facility.
Configure runtime and redelivery together. Django Q2's retry
documentation
warns that a receipt-capable broker may start a duplicate when retry is
shorter than the running task.
5. What state must users inspect?
Do not make a broker task ID the only explanation of a business operation.
Persist states that match the user's object: queued, processing, ready,
and failed are more useful than a queue-specific status hidden in an
operations console.
This also stabilizes your API. You can change from Q2 to another worker without
changing the meaning of GET /exports/42 or rebuilding the support workflow
around a different broker.
6. Is the operational cost justified?
An async view needs careful ASGI deployment, async-compatible middleware, and async-safe dependencies to provide its full benefit. A background system adds a queue or backend, workers, deployment health, retry policy, backlog monitoring, and recovery tools.
Keep fast required work synchronous when that is the clearest design. Use an async view when concurrent waiting makes the request materially better. Add a worker when the job's lifetime or failure contract requires one. Each option has a real cost.
When to use a Django async view
The strongest async-view case is concurrent, read-oriented I/O needed by one response. For example, a dashboard may need independent data from a billing provider and a deployment provider:
import asyncio
from django.http import JsonResponse
async def service_health(request):
billing, deployment = await asyncio.gather(
billing_client.health(),
deployment_client.health(),
)
return JsonResponse(
{
"billing": billing,
"deployment": deployment,
}
)
This pattern can reduce wall-clock latency when the calls are independent and
both clients are genuinely asynchronous. Calling blocking code directly inside
async def blocks the event loop; adapting synchronous code adds a sync/async
boundary crossing.
Django 6.0 provides asynchronous query methods but does not support database
transactions in async
mode.
Methods include afirst(), acreate(), and asave(). Django recommends
placing transactional database work in one synchronous function and calling it
through sync_to_async().
Keep that boundary coarse: one adapter call around a coherent database
operation is easier to reason about than wrapping every query separately.
Also inspect middleware before expecting ASGI concurrency. Django can adapt synchronous middleware, but that introduces thread usage and reduces the advantage for long-lived, non-ORM I/O. Measure the whole request stack, not only the view function.
When to use a background task
Choose a worker when completion belongs to the application rather than the connection. Common examples include:
- generating archives, reports, thumbnails, or media;
- sending email and non-critical notifications;
- processing provider webhooks after acknowledgement;
- importing or exporting data;
- running scheduled maintenance;
- applying rate-limited integration work;
- retrying a recoverable side effect.
Django 6.0's Tasks framework gives Django a standard contract for defining, queueing, and tracking tasks. It does not ship a production worker. Its built-in Immediate and Dummy backends are intended for development and testing, so production still needs an external backend and worker process.
Task inputs should be identifiers and small serializable values. Django Tasks requires arguments and return values to survive a JSON round trip. The same discipline helps Q2 and Celery jobs remain retryable and keeps current database state inside the worker rather than frozen in a serialized model object.
The Djass Q2 operations guide covers the worker command, queue settings, and failure checklist used by generated projects.
A real boundary: Djass project generation
Djass generates a complete repository ZIP. That work performs template generation, filesystem writes, hashing, storage, and status updates. It does not belong inside the request, even if parts of it could use async I/O.
The current repository makes the main ownership decisions explicit:
- UI, API, and MCP entrypoints validate input and create a
Projectinqueuedstate. - Each entrypoint enqueues the same
generate_project_artifactworker function with a project ID. - The worker reloads current state and moves the project to
generating, using the lifecycle values in theProjectmodel. - It creates the repository, writes standardized generation metadata,
packages the ZIP with sorted traversal and fixed archive timestamps,
records size and SHA-256, and marks the project
ready. - On failure, it records an error and marks the project
failed. - The UI and API expose state, while an explicit retry action requeues failed generation.
This workflow covers five of the six placement boundaries directly. The
response does not need the ZIP. Generation must survive disconnects, has a long
runtime budget, and needs inspectable state plus recovery. The current
entrypoints create the project under Django's default autocommit behavior
before enqueueing it. If that create-and-enqueue sequence moves inside an
explicit transaction, dispatch should move to transaction.on_commit().
Djass runs qcluster in a separate worker process. Its current Q2 settings use
four workers, a 3,600-second timeout, a 4,800-second retry interval, and a
maximum of two attempts. Those numbers document the artifact-generation
workload; they are not universal queue defaults.
The project-generation pipeline shows the full sequence. The generator options reference shows what the resulting repository can include.
Test the boundary, not only the function
For an async view, test:
- the response with successful concurrent calls;
- one upstream timeout or cancellation;
- the sync/async adapter boundary;
- ASGI middleware compatibility;
- transaction-dependent code in a synchronous helper;
- measured latency and connection behavior under representative load.
For a background task, test:
- dispatch only after commit where required;
- small, serializable inputs;
- success and user-visible state changes;
- rollback without dispatch;
- duplicate execution and idempotency;
- worker failure, retry exhaustion, and explicit recovery;
- queue-unavailable behavior at the request boundary;
- a real worker integration path in addition to synchronous unit tests.
The key assertion is ownership. An async-view test should prove the request produces the required response. A worker test should prove the application can explain and recover the operation after the request is gone.
Django async views vs background tasks: the final rule
Use a Django async view to wait on multiple async resources for the response you are building now. Use a background task to own work after that response, especially when it needs independent retries, durable state, scheduling, or a longer runtime.
Do not move work to a worker only because it is slow before checking whether
the user needs the result immediately. Do not put durable work in an async view
only because await makes it look non-blocking. Choose the owner first, then
choose the execution model.
If you want a maintained Django SaaS repository with Q2 workers, lifecycle state, API and MCP generation paths, and deployment wiring already included, review the current Djass lifetime pricing.