What belongs in a background job
coreintermediateA request should do the smallest amount of work that lets it answer honestly, and hand everything else to a worker. Sending email, rendering a report, resizing an image, transcoding a video and fanning out notifications all share the same three properties: they are slow, they depend on something outside your database, and the user does not need their result to know their action succeeded. Keeping any of them inline means the response time is the sum of every third party you talk to, and a failure in the least important step — the welcome email — fails the whole request. The pattern is: write the row, return the response, and enqueue the side effect from `transaction.on_commit()`.
Think of it as
The test is not "is this slow?" but "does the caller need this to have finished before I can honestly answer?". A signup is complete when the user row exists; the welcome email is a consequence, not part of the fact. Framing it that way settles most cases immediately and also exposes the ones that are genuinely inline — you cannot enqueue a payment authorisation and tell the user their order is confirmed, because the confirmation depends on it. The second half is *when* to enqueue, and it is the part that goes wrong most often. Calling `.delay()` inside a transaction means the message can reach a worker before the transaction commits, so the worker looks up a row the database will not show it yet. `transaction.on_commit()` defers the enqueue until after the commit, which fixes both directions: the worker never sees a missing row, and a rollback silently cancels the job instead of sending an email about an order that no longer exists. The third thing worth deciding early is what the user sees. "Your report is being prepared" with a job id and a poll endpoint is a better product than a thirty-second spinner, and it is also the only shape that survives a worker restart.
What we're doing: A signup and a report request that both answer immediately, with their side effects enqueued safely.
- 6–8
- The enqueue is registered inside the block but *runs* after the commit. Calling `.delay()` directly here would let a worker read `user.pk` before the row is visible.
- 8
- `user.pk`, not `user`. The argument is serialized into the message, so passing the instance means the worker acts on a snapshot that may already be out of date.
- 16–18
- A row representing the job, created before the task is enqueued, is what makes the work observable — the client has something to poll and support has something to look at.
- 20–24
- 202 with a job id and a poll URL. This is a better product than a spinner and it is also the only shape that survives the worker being restarted mid-report.
Why this works: Both endpoints answer in milliseconds, both are safe against a rollback, and both leave a durable record of the work — none of which is true if the side effect runs inline.
Calling `.delay()` inside the transaction
Wrong
Better
What you see: `Order.DoesNotExist` in the worker for an order that plainly exists by the time you look. It happens under load and never in development, because a local worker is slow enough to lose the race that a busy one wins.
Why: A row created inside a transaction is invisible to other connections until `COMMIT`, but the broker is not part of that transaction — the message is available the instant `.delay()` returns. A worker that picks it up in the milliseconds before the commit queries a database that has never heard of the order. `on_commit()` defers the enqueue past the commit, and as a bonus discards it entirely if the transaction rolls back.
- Client → Django: POST /signup/
- Django → Database: BEGIN; INSERT user
- Django → Worker: inline: connect to SMTP and send (the response now waits on a third party you do not control)
- Worker → Django: SMTP times out after 30 s
- Django → Client: 500 — and the user row was rolled back (a failed welcome email destroyed a successful signup)
- Client → Django: POST /signup/ (enqueued version)
- Django → Database: BEGIN; INSERT user; COMMIT
- Django → Worker: on_commit → send_welcome.delay(user.pk) (after COMMIT, so the worker can always find the row)
- Django → Client: 201 Created — in 40 ms
- Worker → Database: SELECT user 57; send; retry on failure (a failure retries instead of destroying the signup)
The section's five, and why each one leaves the request
Together
Remember: The test is whether the caller needs the result to know their action succeeded — not whether it is slow. Email, reports, image and video processing, and notification fan-out all fail that test, so they leave the request. Enqueue from `transaction.on_commit()`, never inside the transaction, or a worker can look for a row that has not committed; `on_commit` also cancels the job automatically on rollback. Pass an id, never an instance. And return 202 with a job id and a poll URL rather than holding the connection.
See also: bulk scheduled and external work · celery app tasks and workers · on commit and transaction timing

