Back to all writing

How to Track Background Job Progress in React and FastAPI

Track FastAPI background jobs in React with persisted job IDs, status endpoints, safe polling and refresh recovery. Includes a runnable local example.

A detachable blue viewing panel reconnects to a persistent job core inside a metal and glass chamber, with an amber report at the output

Some actions in a web application take more than a few seconds. Writing a report, importing a large spreadsheet or processing a document can take long enough that someone switches tabs, loses their connection or refreshes the page.

The application needs to help them return to that work and understand what happened.

Consider an application that researches a topic and writes a report for you. You enter a question, click Write report, and the application starts working. Before it finishes, you refresh the page.

Now the progress indicator has disappeared, and the Write report button is back. Is the application still writing your original report? Did something fail? Will clicking again start a second report?

In this article, I’ll build a small React and FastAPI example that keeps track of a report from start to finish. It saves the job, shows its progress and lets the browser find the same job after a refresh. When the report is ready, the screen offers a download.

The example draws on my work on Research-AI, an application that turns a research question into a cited report. The example runs independently and writes three fixed sections so you can try the behaviour without a research service or model account.

What the finished view should do

The report screen should make four things possible:

  • Start writing a report and see which section is being written.
  • Refresh the page and return to the same report.
  • Keep the last recorded progress visible if the connection drops, with an explanation that updates are temporarily unavailable.
  • Download the finished report, or see an explanation if writing fails.

How the pieces fit together

A background job is work the server can continue after replying to the browser. To keep track of it, this example has three parts:

  1. A saved job: a database record containing the report’s identifier, current state and progress.
  2. A worker: the code that writes the report and saves progress after each section.
  3. A React screen: the interface that asks FastAPI for the latest saved progress and displays it.

The screen checks for updates at regular intervals. This is called polling. Refreshing the page starts a new view of the saved job; it does not need to start the work again.

The request flow is small enough to follow end to end:

React -- POST /api/jobs --> FastAPI -- commit --> SQLite job
      <-- 202 + job ID ---          

Single local worker -- save each completed section --> SQLite job

React -- GET /api/jobs/{id} ------> latest saved snapshot
      -- GET /api/jobs/{id}/result --> completed report

SQLite stores the jobs for this local example. The repository includes the React screen, polling hook, FastAPI API, worker and restart tests. Its README explains how to run the application and try refreshing or interrupting it.

View the complete example on GitHub. Clone the repository and follow its README.md. You will need Python 3.12 or newer, uv and Node.js 24. The demo runs locally; it needs no paid services or API keys.

Give the browser a job it can find again

My starting point is a small contract:

  1. The API validates the request and saves a job.
  2. It acknowledges that saved job with an identifier.
  3. A worker advances the job and stores its progress.
  4. The browser checks the job until it completes or fails.

Create and save the job

The acknowledgement has a specific meaning. The system has accepted responsibility for the work. It has not finished the work.

For a new API, I would express that with a response like this:

HTTP/1.1 202 Accepted
Location: /api/jobs/8ac7d6a7-9b14-4fb3-a334-04d38b5b9cc2
Retry-After: 2
Cache-Control: no-store
Content-Type: application/json

{
  "id": "8ac7d6a7-9b14-4fb3-a334-04d38b5b9cc2",
  "status": "queued"
}

This follows the asynchronous request-reply pattern: accept the request, then expose a separate resource for its status. The paths and payloads here belong to the article's example; they are not Research-AI's exact API.

Save the job before returning that response. Starting an in-memory task and returning a UUID gives the browser something to display, but it does not make the work survive a process restart.

Here is the creation endpoint from the companion's app.py:

class NewJob(BaseModel):
    mode: Literal["success", "failure"] = "success"


@app.post("/api/jobs", status_code=202)
async def create_job(body: NewJob, response: Response):
    job_id = str(uuid4())
    with database() as connection:
        connection.execute(
            "INSERT INTO jobs (id, status, mode) VALUES (?, 'queued', ?)",
            (job_id, body.mode),
        )
    response.headers.update({
        "Location": f"/api/jobs/{job_id}",
        "Retry-After": "2",
        "Cache-Control": "no-store",
    })
    return {"id": job_id, "status": "queued"}

database() opens a SQLite connection, commits when the block exits successfully, rolls back on an exception, and closes the connection. The response is constructed after that commit. If the insert fails, this handler cannot return a successful acknowledgement.

The worker claims saved queued jobs. There is no separate enqueue call between the commit and response to lose in this small example. On startup, the single worker requeues interrupted jobs and resumes after their last saved section. That recovery works here because the report consists of local deterministic text; repeating a payment or another external write would need a different recovery contract.

Read the saved progress

Reading progress is separate from starting work:

def get_job(job_id):
    with database() as connection:
        row = connection.execute(
            "SELECT * FROM jobs WHERE id = ?", (str(job_id),)
        ).fetchone()
    if row is None:
        raise HTTPException(404, "Job not found")
    return row


@app.get("/api/jobs/{job_id}")
async def read_job(job_id: UUID, response: Response):
    response.headers["Cache-Control"] = "no-store"
    return job_snapshot(get_job(job_id))

job_snapshot() returns the stored state, section count, stage label and result availability shown below. It does not launch a worker. Repeated reads and page refreshes therefore cannot create another report. These are excerpts from the complete local example, which supplies the imports, schema, snapshot function and worker lifecycle.

Check who can access the job

A job identifier tells the API which job to find. It does not prove that the person requesting it has permission to see it. Both the status endpoint and the result endpoint must check whether the signed-in person can access that job. An unguessable identifier does not replace that check.

The local example deliberately has no accounts and binds to loopback. Before adapting it into a hosted application, scope the job lookup to the authenticated owner's jobs or the application's explicit sharing policy; never accept an owner ID from the browser as proof of access.

Research-AI's task-status route resolves the job's session and verifies that the current user has access to it before returning progress or output.

Does FastAPI BackgroundTasks solve this?

FastAPI's BackgroundTasks runs work after the response in the application process. It is useful for small follow-up tasks, but adding a function to it does not create a durable queue, persist progress or recover interrupted execution.

For a report that must survive worker restarts, I still need saved job state and a recovery strategy. For heavier work or multiple workers, a separate worker system may be appropriate; FastAPI's documentation discusses that distinction. Choosing a worker system changes execution. It does not remove the browser's need for a stable job ID and readable status.

Track the job and the connection separately

The job needs only a few public states:

Job stateWhat the application can sayUseful next action
QueuedThe request was accepted and is waiting to run.Leave and return to this job.
RunningThe worker has started. Show the latest recorded stage.Continue waiting or use another part of the app.
CompletedA result is available.Open or download it.
FailedThe worker stopped because writing failed.Read the explanation and use an appropriate recovery action.

The browser also needs to know whether it is currently able to read that state.

A failed status request means the browser could not obtain a current snapshot. It does not establish that the worker failed: the problem could be the network, the status API or the response itself. The worker might have finished successfully while the response was being lost.

I keep those facts separate:

Last known job: running, writing the report
Connection: unable to refresh its status

That can produce a useful message:

I couldn't refresh this report's status. The last update was “Writing the report.” I'll check again.

Compare that with “Report failed. Try again.” The second message invites another expensive operation without knowing what happened to the first.

Research-AI's polling hook already distinguishes a recorded job failure from ordinary polling errors. A polling error delays another status check; a completed or failed job ends polling.

There are also errors that repeated polling will not solve. If the session has expired, ask the person to sign in again. If the job cannot be found or is no longer accessible, explain that the application cannot retrieve it. Neither response proves that the underlying computation failed.

Show progress you can account for

Research does not have a reliable conversion from seconds elapsed to percentage complete.

An outline might finish quickly. One source might take much longer to retrieve than the others. A later stage might discover that it needs more work. Moving a bar towards 95% on a timer hides all of that behind a number the system invented.

I prefer progress that names something real:

Outline prepared
Researching the selected perspectives
Writing the final document

If the application knows a useful count, show the count with its unit:

3 of 8 sections written.

That is different from “38% complete.” The remaining sections might have very different costs, and final assembly may not have started.

Research-AI's progress model can expose stage descriptions and individual expert progress. Those are useful explanations of activity without pretending to know the finishing time.

When the worker knows how many sections it needs to write, the API can return:

{
  "id": "8ac7d6a7-9b14-4fb3-a334-04d38b5b9cc2",
  "status": "running",
  "progress": {
    "label": "Writing section 2 of 3",
    "completed": 1,
    "total": 3
  },
  "result_available": false,
  "error": null
}

Here the count measures completed sections, not elapsed time or all the work left in the system.

A native HTML progress element is enough for that display:

<label htmlFor="report-progress">Sections written</label>
<progress
  id="report-progress"
  max={job.progress.total}
  value={job.progress.completed}
/>
<p role="status">{job.progress.label}</p>

When the total amount of work is unknown, omit the value attribute and use an indeterminate indicator with a stage label. MDN documents both that behaviour and the need for an accessible label.

I would announce meaningful stage changes, rather than making a screen reader repeat a changing timer every second. The text should explain the state even if the animation is disabled.

A polling loop must not outrun its own requests

The easy implementation is an interval:

setInterval(fetchStatus, 1500);

If a status request takes four seconds, that loop can start several requests for the same job before the first one finishes. Their responses can arrive out of order.

I would schedule the next request after the previous one settles. Give each request a timeout, back off after connection errors, and stop when the job completes or fails.

This is the core of the React hook in the companion example:

useEffect(() => {
  let disposed = false;
  let timer;
  let request;
  let failures = 0;

  async function poll() {
    request = new AbortController();
    const deadline = setTimeout(() => request.abort(), 10000);
    let delay = 2000;

    try {
      const response = await fetch(
        `/api/jobs/${encodeURIComponent(jobId)}`,
        { signal: request.signal, cache: "no-store" }
      );
      if (disposed) return;

      if ([400, 401, 403, 404, 422].includes(response.status)) {
        setView(previous => ({
          ...previous,
          connection: "unavailable",
          message: "This job is unavailable. Check the identifier and your access.",
        }));
        return;
      }
      if (!response.ok) throw new Error("Status request failed");

      const job = readJob(await response.json(), jobId);
      if (disposed) return;

      failures = 0;
      setView({ job, connection: "connected", message: "" });
      if (["completed", "failed"].includes(job.status)) return;
    } catch {
      if (disposed) return;
      failures += 1;
      delay = Math.min(10000, 2000 * 2 ** failures);
      setView(previous => ({
        ...previous,
        connection: "reconnecting",
        message: "Unable to refresh status. Checking again.",
      }));
    } finally {
      clearTimeout(deadline);
    }

    if (!disposed) timer = setTimeout(poll, delay);
  }

  void poll();
  return () => {
    disposed = true;
    clearTimeout(timer);
    request?.abort();
  };
}, [jobId]);

readJob validates the response shape and checks that it belongs to the requested job. The surrounding component keeps this state scoped to that job; it must not briefly display the previous report when the identifier changes.

In the example, the parent renders <JobProgress key={jobId} jobId={jobId} />. Changing the key gives the next job a fresh view and cleans up the previous polling effect. Keep the hook inside that keyed component, and mount it only when a job identifier exists.

Aborting the request saves work on teardown. The disposed check also prevents a late response from updating a view that has gone away. React's Effect documentation explains why cleanup is necessary when network responses can arrive after the view has changed.

In an application that already uses a query library, I would usually implement these rules through that library's polling, cancellation and retry facilities. This hook keeps the behaviour visible for the example; it is not a reason to add a second fetching system.

The example's deterministic backoff is easy to test. For many simultaneous clients, I would add jitter and account for server retry guidance, including rate-limit responses. I would also avoid repeatedly checking hidden tabs when the product does not need that activity, and refresh promptly when the person returns.

When would I use SSE instead?

For progress that changes every few seconds, I would usually begin with polling. It is easy to inspect, works with an ordinary status endpoint, and each successful response can restore the complete current view.

SSE becomes attractive when updates are frequent enough that polling creates noticeable delay or repeated requests with little new information. FastAPI has support for server-sent events, which lets the server stream updates over an HTTP connection.

I would make the choice around the application:

RequirementStarting point
Occasional updates for a report or exportPoll a status endpoint.
Frequent one-way updates such as streamed outputConsider SSE.
Ongoing two-way interaction over one connectionConsider whether WebSockets fit that interaction.

The number of open clients matters too. A two-second poll interval across many active jobs can become considerable traffic. Measure the request rate, status-query cost and update latency before treating an interval as a permanent setting.

With SSE, I still keep the status endpoint. A disconnected browser needs a way to recover the current job. An event ID helps only if the server can replay the corresponding retained events; setting an ID on a message does not create replay storage.

For a progress display, reconnecting and fetching a fresh snapshot is often sufficient. An audit history has different requirements.

Reloading should recover the job, not create another one

The job's identity must outlive the component.

For a simple application, a URL such as /reports/jobs/<job-id> is a useful starting point. Refreshing it can fetch that job again. A saved report list lets the person find it later or from another device.

Local storage can help remember a job identifier in one browser. It cannot replace the server's job history or its access checks.

Research-AI restores the active task from the saved session response. Its hook can reconstruct the pending message and progress state from that snapshot. The person can return to the session without relying on the original spinner still being mounted.

This recovery also matters when the first acknowledgement never reaches the browser.

The API might have saved the job before the connection timed out. Automatically repeating the create request could enqueue another report. An application that needs safe create retries should support an idempotent creation contract or another reliable way to reconcile the original request.

I covered the underlying ambiguity in A Timeout Doesn't Tell You Whether the Write Happened. Your API Accepts an Idempotency Key. That Does Not Make It Idempotent goes into what a safe retry contract has to preserve. On this screen, retrying a status read and starting new work must be different actions.

The companion does not claim idempotent creation. After an uncertain acknowledgement, it asks the person to refresh the saved jobs list and open the existing report before deciding to start another one.

Completion needs something the person can use

A green tick is a weak ending if there is no report behind it.

I want the worker to save the result before exposing completion. The interface then needs an obvious way to open it, plus a sensible response if the result cannot be retrieved.

These are separate failures:

  • The worker recorded a failure while generating the report.
  • The job says completed, but the response contains no usable output.
  • A valid result exists, but the browser cannot download it right now.

The last case should offer another attempt to retrieve the same result. It should not default to generating the report again.

Research-AI explicitly handles a completed response with empty output instead of silently displaying success. That does not prove the document is factually correct; it prevents a different mistake, claiming that a result was delivered when the response contains none.

For downloadable reports, I would also make retention and expiry visible if those policies apply. “Completed yesterday” is not enough context if the download has since expired.

Exercise the awkward states

Start the API and React view using the companion's README. Create a report, keep its ?job=<id> URL open and refresh during generation. Then stop and restart the API. The same job should resume with its saved sections intact.

Select the deliberate worker-failure mode for a different check: a recorded failure should stop polling and explain the failure. Taking the browser offline should preserve the last known progress and attempt to reconnect. Those two actions must not produce the same screen.

These are the checks I care about:

ActionExpected behaviour
Refresh during generationThe same job is recovered; no second create request is sent.
Block status requests temporarilyThe last known progress stays visible with a connection warning.
Restore the connectionThe next successful status read catches up.
Complete while the browser is offlineReturning shows the existing result.
Make a request slower than the polling intervalRequests do not accumulate for that view.
Open a different jobAn old response cannot replace the new job's state.
Return a recorded job failurePolling stops and the failure is explained.
Return an inaccessible jobThe UI stops polling without claiming the work failed.
Report completion without outputThe UI exposes the inconsistency instead of offering an empty success.

I also check the view with a keyboard and without motion. A moving indicator cannot be the only explanation of what is happening.

The useful outcome is that someone can start a report, leave, come back and understand what happened. The Research-AI case study shows how that interaction fits into the wider application.

A background job moves work out of a request. The interface still has to carry the person from asking for that work to receiving its result.