skip to content

A queued background job receives an object loaded by a finished web request and fails reading a deferred link - what went wrong?

level: seniorimportance: should knowfreq 48%

answer

  1. the handle travelled, the machinery did not
  2. detached, stale, unsafe to share
  3. send keys, not objects
  4. the job reads in its own transaction

basics

~20 s

A mapped object was used as a message. Its unit of work ended with the request, so the deferred link has no loader and the filled fields are a stale snapshot. Send identifiers instead, and let the job read in its own unit of work.

solid answer

~50 s

The object crossed a boundary it was never valid across. When the request's unit of work closed, the object became detached: its stand-ins still promise to fetch, but the transaction and connection that backed them are gone, so the job's first read of an unfilled link fails - or, on a quieter layer, comes back empty. Two further problems ride along even for the fields that *are* filled: the values are a snapshot from enqueue time, so the job may act on state the database has since changed, and a mapped object is generally not safe to share across threads. The repair is to make the message a plain payload - identifiers plus the few values the job needs - and have the job open its own unit of work and read exactly what it requires.

go deeper

for a junior

Do not put database objects into messages or thread arguments. Send identifiers and simple values, and let the worker load what it needs itself, inside its own transaction.

for a middle

Separate the three failures: the deferred link has no loader, the loaded fields are stale, and mapped objects are not safe to share across threads. Each needs naming; only the first produces the obvious error.

for a senior

Show the design: identifier payloads, a re-read in the job's own unit of work, explicit handling of the changed-or-deleted cases, and pinned values carried as plain data when the work depends on state at enqueue time.

for a principal

Make it a typed boundary rule - no mapped type in a message, task argument or cache - so the loading, staleness and concurrency hazards are all caught by one reviewable convention rather than by three separate habits.

## What actually crossed the boundary A mapped object looks like a value and behaves like a handle. Passing it to a queue, a thread pool or a scheduled task moves the handle without the machinery behind it. Three separate things break, and it helps to name them separately because they need different fixes. - **No loader.** The unit of work that created the stand-ins closed with the request. Any link not already filled cannot fill itself, so the first read fails or silently yields nothing. - **A stale snapshot.** The fields that *were* loaded hold values as of the moment of the read that loaded them. The job may run seconds or hours later, after other writers have changed those rows. - **No safe sharing.** Mapped objects, and the instrumented collections inside them, are generally built for use by one unit of work on one thread at a time. Passing one to a worker adds a concurrency hazard on top of the loading one. ## Why it is worse than the in-request version of this failure Inside a request, the failure is immediate and visible in the response. In a job, it happens on another thread at another time, so it surfaces as a failed or retried job, often with a stack trace nobody associates with the endpoint that enqueued it. If the queue serialises its payloads, the failure can also change shape entirely: the object may be flattened at enqueue time, and whatever the flattening did to the unfilled links becomes the job's view of reality. ## What to send instead 1. **Identifiers and the minimum plain data.** The job receives the keys of what it must act on, plus any values that must be pinned to enqueue time - a decided amount, a chosen recipient - and nothing else. 2. **A read inside the job's own unit of work.** The job opens its own transaction and loads exactly the graph it needs, using the same explicit shape rules any other read follows. 3. **A fully materialised transfer model, when a snapshot is genuinely wanted.** If the job must act on state as it was when the event happened, send a flat structure with no deferred links and no mapped types, so the intent is explicit rather than accidental. | payload style | when it fits | risk | |---|---|---| | mapped object | never | detached links, stale fields, sharing hazards | | identifiers plus plain values | the default for most jobs | the row may have changed or gone - the job must handle both | | flat transfer model snapshot | the job must act on state as of the event | the snapshot can be stale by design; say so explicitly | ## The re-read is a feature, not overhead Teams sometimes resist sending identifiers because it means an extra read. That read is what makes the job correct. It runs in the job's own transaction, sees the current state, and lets the job discover the two cases that matter: the row has changed since enqueue, or it no longer exists. Both are ordinary in asynchronous systems, and a job carrying a stale object simply cannot detect either. The identifier payload also keeps messages small and stable, so a mapping change does not alter what is already sitting in the queue. ## The one thing the job must decide explicitly If the work depends on the state at enqueue time - the price quoted, the permission held then - that state has to travel in the payload as data. Do not reach for the mapped object because it happens to hold the old values; that is relying on an accident of loading. Put the pinned values in the message deliberately, and re-read everything else. ## A note on retries Jobs are usually retried, and a payload carrying a mapped object makes retries worse rather than better. Every attempt replays the same detached object, so a failure caused by an unfilled link is perfectly reproducible and burns the retry budget without ever succeeding. An identifier payload behaves the opposite way: each attempt reads fresh state, so a retry can succeed once a transient problem clears, and a row that has genuinely vanished produces a definite outcome instead of an endless loop. ## How this shows up in review The reviewable signal is a **type**, not a line of logic: a mapped type appearing in a queue payload, a task argument, a thread-pool submission or a cached value. That is a much sharper rule than any attempt to spot the eventual read, and it catches the stale-data and thread-safety problems in the same pass as the loading one.

  • The job needs the values as they were when the event happened. Does that justify sending the object?
    No - it justifies sending a snapshot deliberately. Put the pinned values in the message as plain data, so the intent is visible and the payload is stable. Relying on whichever fields happened to be loaded is an accident of the read, and any unfilled link still fails.
  • What must a job that re-reads by identifier handle that one carrying an object does not?
    That the row has changed, or has been deleted, between enqueue and execution. Both are normal, and both need a decision: proceed, skip, or fail loudly. A job holding a stale object cannot even detect them, which is a reason to prefer the re-read, not to avoid it.
  • Is there a version of this failure with no queue involved?
    Yes - handing a mapped object to a thread pool, a scheduled task or a cache has the same shape. The unit of work ends while the object lives on. The queue only adds serialisation, which can flatten unfilled links into misleading empty values.

saying these in an interview costs you the question

  • Puts a mapped object into a queue payload or task argument
  • Thinks serialising the object at enqueue time fills its links
  • Assumes the snapshot is current when the job finally runs
  • Shares one mapped object across worker threads
  • Treats the job re-reading by identifier as wasteful overhead