Back to blog
By Rasul

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:

  1. The response needs the result.
  2. Most of the wait is async-compatible network I/O.
  3. The work should stop if the request goes away.
  4. The latency fits your HTTP timeout budget.

Use a background task when any of these are true:

  1. The work should continue after the response.
  2. It can take long enough to threaten an HTTP timeout.
  3. It needs retries, scheduling, workload isolation, or rate control.
  4. 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:

  1. UI, API, and MCP entrypoints validate input and create a Project in queued state.
  2. Each entrypoint enqueues the same generate_project_artifact worker function with a project ID.
  3. The worker reloads current state and moves the project to generating, using the lifecycle values in the Project model.
  4. 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.
  5. On failure, it records an error and marks the project failed.
  6. 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.