Tracing FastAPI Background Tasks

A FastAPI background task runs after the response has been sent, which means the request's span has already ended. Making that work appear as a child of the request produces a waterfall where a child starts after its parent finished. This page covers the correct shape — a linked root span — and the context capture that has to happen while the handler is still running. It builds on async tracing patterns, part of the distributed tracing and OpenTelemetry in Python section.

The background task starts after the span it belongs to has ended A request timeline showing the ordering problem. The server span covers the handler's work and ends when the response is sent — that is what the span measures and it is correct. The background task begins after that point, runs for another two hundred milliseconds, and finishes well outside the span's window. Modelling it as a child span produces a waterfall in which the child's start timestamp is later than the parent's end timestamp, which most backends render as either a visual glitch or a span floating outside its parent's bar, and which misrepresents what happened: the request did not wait for this work. Modelling it as a new root span with a link back to the request's span context represents it accurately — two related units of work, one after the other, connected without a containment claim. The note added is that this is the same shape used for any work that outlives the operation that scheduled it, including queue consumers. POST /orders — response sent at 84 ms, work continues to 290 ms SERVER span · ends here the request response sent as a CHILD span — starts after its parent ended wrong shape misleading as a linked ROOT span · 206 ms right shape link, not parent what each shape claims child: this work happened inside the request, and the request's duration includes it — neither is true link: these two units of work are related, one scheduled the other — which is exactly what happened the same shape fits any work that outlives its scheduler, including a queue consumer picking up a message
The child-span shape claims the request's duration includes the background work. It does not — that is the whole reason the work was backgrounded.

Prerequisites

pip install "fastapi>=0.115.0,<1.0.0" \
            "uvicorn[standard]>=0.30.0,<1.0.0" \
            "opentelemetry-sdk>=1.27.0,<2.0.0" \
            "opentelemetry-instrumentation-fastapi>=0.48b0,<1.0.0"
export OTEL_SERVICE_NAME=orders-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

Implementation

Step 1 — Capture the span context inside the handler. By the time the background callable runs, get_current_span() returns whatever is current then, which is usually nothing.

from fastapi import BackgroundTasks, FastAPI
from opentelemetry import trace
from opentelemetry.trace import Link, SpanContext

app = FastAPI()
tracer = trace.get_tracer(__name__)

@app.post("/orders")
async def create_order(payload: dict, background: BackgroundTasks):
    order = await repository.create(payload)

    ctx = trace.get_current_span().get_span_context()      # captured NOW, while it is valid
    background.add_task(send_confirmation, order.id, ctx)

    return {"id": order.id}

Step 2 — Start a linked root span in the task.

def send_confirmation(order_id: int, request_ctx: SpanContext) -> None:
    links = [Link(request_ctx)] if request_ctx.is_valid else []
    with tracer.start_as_current_span(
        "orders.send_confirmation",
        links=links,
        kind=trace.SpanKind.INTERNAL,
    ) as span:
        span.set_attribute("order.id", order_id)
        span.set_attribute("request.trace_id", format(request_ctx.trace_id, "032x"))
        mailer.send(order_id)

The request.trace_id attribute is belt and braces: backends render links inconsistently, and an explicit attribute means a text search for the trace ID finds the background span even when the visual link is not displayed.

Step 3 — Copy the context for work you start yourself. asyncio.create_task copies the context automatically; a thread or an executor does not.

import asyncio
import contextvars
from concurrent.futures import ThreadPoolExecutor

pool = ThreadPoolExecutor(max_workers=4)

async def schedule_blocking_work(order_id: int) -> None:
    ctx = contextvars.copy_context()                       # snapshot, including the span
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(pool, ctx.run, do_blocking_work, order_id)

ctx.run(fn, *args) executes the function inside the captured context, so the active span at capture time is the active span inside the thread. Without it, the work runs with an empty context and its spans are orphans.

Does the context follow the work? Three mechanisms for running work outside the request handler, and what happens to the trace context in each. Creating an asyncio task copies the current context automatically at creation time, so a span started inside the task is a child of whatever was active when create_task was called — this works without any effort, and it fails only when the task is created before the span exists. Submitting to a thread pool executor does not copy anything: the worker thread has its own empty context, so spans started there are orphan roots unless contextvars.copy_context is captured in the caller and the function is run through it. Publishing to a task queue crosses a process boundary, so nothing in memory travels at all: the trace context has to be serialised into the message headers by the publisher and extracted by the consumer, which is what the instrumentation libraries do automatically for supported brokers. The ordering of effort is the point — the first is free, the second is one line, and the third is a protocol. three ways to start background work asyncio.create_task context: copied automatically at creation time a span started in the task is a child of the one that was active fails only if created too early ThreadPoolExecutor context: not copied the worker thread starts empty spans there are orphan roots fix: contextvars.copy_context() and run through ctx.run a task queue context: nothing travels a different process entirely serialise into the message headers and extract on the consumer the instrumentations do this the effort ladder free · one line · a protocol — and the failure looks the same in all three cases: a span with no parent and no explanation
Only the first is automatic. The other two fail the same way — an orphan span — and neither raises anything to tell you.

Step 4 — Flush at shutdown. Background work finishing during termination can produce a span that never leaves.

from contextlib import asynccontextmanager
from opentelemetry import trace

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    provider = trace.get_tracer_provider()
    provider.force_flush(timeout_millis=5000)
    provider.shutdown()

app = FastAPI(lifespan=lifespan)

Step 5 — Know when to stop using background tasks. A BackgroundTask runs in the serving process and dies with it. If losing the work on a rolling restart is unacceptable, it belongs in a queue — and then the context travels in the message, as described in propagating trace context across Celery tasks.

The shutdown race a background task loses A termination timeline. SIGTERM arrives and the server stops accepting new requests. In-flight requests finish and their spans are queued. The lifespan shutdown hook runs and calls force_flush, which drains everything queued at that instant and exports it. But a background task scheduled by the last request is still running: it finishes two hundred milliseconds later, ends its span, and puts it on a queue that has already been flushed and is about to be shut down — so that span is never exported. The symptom is background spans that go missing shortly before every restart, which looks like an intermittent instrumentation bug and is actually deterministic. The remedy shown is to await outstanding background work in the lifespan before flushing, and to give the container a termination grace period long enough for both. SIGTERM → drain → flush → exit SIGTERM in-flight requests finish force_flush() export the background task, still running its span is queued after the flush — never exported the remedy await outstanding background work in the lifespan before flushing — and size the grace period for both, not just for requests
It looks intermittent and it is deterministic: the last request's background task always finishes after the flush.

Configuration options

Concern Choice Recommended
Relationship child vs link link, for work after the response
Context capture implicit vs explicit explicit, inside the handler
Thread hand-off plain submit vs ctx.run ctx.run with a copied context
Backup correlation link only vs link + attribute both — link rendering varies
Durability BackgroundTasks vs a queue a queue when the work must happen
Shutdown none vs force_flush force_flush in the lifespan

Verification

curl -s -XPOST localhost:8000/orders -d '{"item":"x"}' -H 'content-type: application/json'

Expected Output (Collector debug exporter):

Span #0
    Name           : POST /orders
    Kind           : Server
    Trace ID       : 4bf92f3577b34da6a3ce929d0e0e4736
    Span ID        : 00f067aa0ba902b7
    End time       : 2026-08-02 15:02:11.084

Span #1
    Name           : orders.send_confirmation
    Kind           : Internal
    Trace ID       : 9c1e5a2b7d4f3608a11c2e5b90f34d77
    Parent ID      :
    Start time     : 2026-08-02 15:02:11.086
    Links:
         -> Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
            Span ID: 00f067aa0ba902b7
    Attributes:
         -> request.trace_id: Str(4bf92f3577b34da6a3ce929d0e0e4736)

The shape to check: the background span has its own trace ID and no parent, its start time is after the server span's end time, and the link points at the request. That is an accurate description of what happened; a child span with the same timestamps would not be.

Common mistakes

The background span is an orphan

Error signature: the work is traced but nothing connects it to the request. Root cause: the span context was read inside the background callable, where nothing is current, rather than in the handler. Remediation: capture the context in the handler and pass it explicitly.

A child span starts after its parent ended

Error signature: the waterfall renders the background work outside the request's bar, or the backend flags an inconsistency. Root cause: the background work was modelled as a child of the request span. Remediation: use a new root span with a link.

The last background span is missing on every deploy

Error signature: background spans stop appearing shortly before each restart. Root cause: the process exited before the batch processor flushed. Remediation: force_flush in the lifespan shutdown, and give the container a termination grace period long enough for it.

The link relationship is not specific to background tasks; it is the right shape whenever two units of work are related but neither contains the other. Recognising the pattern saves inventing a different answer each time.

A parent-child relationship claims containment. The child started and finished inside the parent's window, and the parent's duration includes the child's. That is true for an outbound call inside a request handler, and false for anything that outlives the operation that started it.

A link claims association without containment. These two units of work are related, one referenced the other, and the timing relationship between them is whatever the timestamps say. It is the right shape for background tasks, for queue consumers picking up a message, for a batch job processing items that were each queued by a different request, and for a retry that happens long after the original attempt.

The batch case is the one where links are most obviously correct and most often done wrong: a job that processes a thousand messages, each queued by a different request, has a thousand relationships and no parent. Modelling it as a child of any one of them is arbitrary; a span with a thousand links is unwieldy but honest, and in practice a link per batch plus the message IDs as attributes is the workable compromise.

Relationship Shape Example
Contained, synchronous parent-child a query inside a handler
Outlives its scheduler link a background task, a queue consumer
Many-to-one links a batch job over messages from many requests
Same work, later attempt link a retry hours after the original
Unrelated nothing do not connect them

Background work that should not be background

A related design question comes up whenever this pattern is used, and it is worth asking explicitly: should this work be a background task at all?

BackgroundTasks runs in the serving process and dies with it, which makes it appropriate for work that is nice to have and inappropriate for work that must happen. The failure is silent — a rolling deploy discards whatever was in flight, and nothing records that it was discarded — which is exactly the shape of problem that surfaces months later as "some confirmation emails never arrive".

The decision usually comes down to one question: if this work is lost, does anyone find out? If yes, it belongs in a queue with the trace context propagated in the message, as described for Celery tasks. If no — a cache warm, a metrics update, an optional notification — an in-process background task is the simpler choice and this page's linking pattern is what makes it observable.

One further habit is worth adopting: give the background span a name that says what the work is rather than that it was backgrounded. A span called orders.send_confirmation is searchable and self-explanatory; one called background_task groups every kind of deferred work in the service into a single unreadable bucket, and the name is the first thing anyone filters on.

Observing what you cannot see

Background work has one further property worth planning for: nobody is waiting for it, so nothing complains when it stops happening. A task that raises produces a span with an error status, which is useful only if somebody looks; a task that is never scheduled produces nothing at all.

Two cheap mitigations cover most of it. A counter incremented at task start and another at successful completion makes the difference between them visible, and a growing gap is the signal that tasks are failing or being discarded. And an alert on the absence of completions over a window catches the case where scheduling stopped entirely — which no error-based signal can, because there is no error.

That is the same argument made for any component that can fail by disappearing, and background tasks are among the most prone to it precisely because their failure inconveniences nobody at the time.

Frequently Asked Questions

Why does my background task's span have no parent?

Because FastAPI's BackgroundTasks run after the response has been sent, at which point the request's span has already ended. A child span of an ended span is not an error but it is misleading: the waterfall shows a child that starts after its parent finished. A link is the correct relationship — it says these two are related without claiming one contains the other.

Does contextvars propagate into a BackgroundTask?

Partially, and version-dependently. Starlette runs background tasks in the same task or in a thread pool depending on whether the callable is async, and the contextvars visible there may or may not include what the request set. Do not rely on it: capture what you need — the span context, the request id — inside the handler and pass it explicitly to the background callable.

Should I use BackgroundTasks or a real queue?

BackgroundTasks is right for short work that is nice to have — sending a notification, writing an audit record — and wrong for anything that must happen, because it runs in the same process and dies with it. If losing the work on a rolling restart is unacceptable, it belongs in a queue with the trace context propagated in the message headers.

How do I see a link in a trace view?

Backends render links differently — some show them as a separate section on the span, some as a dotted edge in the waterfall, some not at all. If your backend does not render links usefully, add the trace id of the request as an attribute on the background span as well, so a search still connects them even when the visual does not.