What belongs in a job, and what belongs in the request
Moving work to a background job doesn't make it faster. It moves who waits, and sometimes that's the wrong trade.
"Move it to a job" is the standard fix for a slow request, and it is often right. It is not free, and the cost is usually invisible until something breaks.
What a job actually changes
It does not make the work faster. The same computation happens, on the same hardware, taking the same time.
What changes is who waits. The user gets a response immediately and the work completes later. That is a genuine improvement when the user does not need the result, and a regression dressed as an improvement when they do.
Sending a receipt is a perfect job — nobody is staring at the screen waiting for it. Generating the page they are about to see is not.
The consistency cost
Once work moves out of the request, the response is a promise rather than a fact.
The user sees "your export is being prepared" and the export might fail. Now you need a way to tell them — a status column, a notification, a page they can check. That is real product surface, and it is frequently discovered after the job is already in production.
This is the trade people underestimate. A job is not a performance optimisation, it is an architectural change that introduces asynchrony into a flow that did not have it.
A background job converts a slow response into a fast lie, and then you owe the user the truth later.
The three questions
Before moving anything, I ask three things.
Does the user need the result to continue? If yes, it stays — or you build a real progress interface, which is a bigger piece of work than the job.
Is it safe to run twice? Jobs retry. If running it twice is harmful, it needs an idempotency key before it needs a queue.
What happens if it never runs? Every queue eventually loses something to a bad deploy or a full disk. If the answer is "nothing important", fine. If the answer is "the customer is never charged", the design needs to survive that.
Enqueue after commit, always
The classic bug: a job enqueued inside a transaction. The worker picks it up, looks for the record, and does not find it — because the transaction has not committed yet.
It is a race, so it passes every test on a developer machine and fails intermittently in
production under load. Enqueue from after_commit, or use the framework's transactional
enqueue support, and the class of bug disappears.
Keep the payload small
Pass an id, not an object. Serialised objects go stale between enqueue and execution, and a job acting on a snapshot of a record from ten minutes ago is a subtle source of wrong behaviour. Look it up fresh, and handle the case where it no longer exists — because sometimes it will not.