Real-Time Task Dashboard with FastAPI
Build a full-stack app streaming live task progress via WebSockets.
Introduction
30 Second Summary
Every time you upload a file or kick off a report online and watch a progress bar fill up, a backend is processing your request and streaming updates to your screen. That kind of real-time feedback is what makes modern apps feel responsive.
In this project, you will build a full-stack task processing dashboard using FastAPI and React. You will submit data analysis tasks, watch them process live through WebSocket streaming, and see results appear the instant they finish.
What You'll Build
You will type a task name, hit submit, and watch the progress bar animate from 0% to 100% in real time as the backend processes it stage by stage until the result appears on screen.
By the end of this project, you'll have:
- A working backend where you submit tasks and watch them process through validation, analysis, and summarization stages, with live progress streamed to your browser over WebSockets.
- A polished React dashboard with animated progress bars, live status indicators, and instant result display driven by real-time WebSocket events.
- Auto-generated Swagger UI API docs at /docs that let you test every endpoint interactively without writing a single line of client code.
- Secret Mission: an optional challenge to push your skills further.
Are there any prerequisites?
This project assumes you are comfortable with Python and basic JavaScript. Familiarity with async/await is helpful but not required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Backend and Frontend Projects
You know what you're building. Before writing any application code, you need two separate environments: a Python backend running FastAPI, and a JavaScript frontend running React.
In this step, you'll create both projects from scratch and get their dev servers running side by side. Getting both environments working now means every future step produces visible output the moment you save a file.
In this step, get ready to:
- Create a Python virtual environment and install FastAPI with Uvicorn.
- Install Node.js and scaffold a React app with Vite.
- Run both dev servers simultaneously.
Set up the Python backend
The backend needs its own isolated Python environment so your project dependencies don't conflict with anything else on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and navigate into the backend directory by running these commands:
mkdir task-dashboard
cd task-dashboard
mkdir backend
cd backend
- Create and activate a virtual environment by running these commands:
macOS
python3 -m venv venv
source venv/bin/activate
Linux users can run these same commands.
Windows
python -m venv venv
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran the activation command for your operating system. On macOS/Linux, the command starts with source. On Windows, it uses the venv\Scripts\activate path.
If you see a permissions error on Windows, try running PowerShell as Administrator and entering Set-ExecutionPolicy RemoteSigned first.
Help me activate my Python virtual environment.
- Install FastAPI and its standard dependencies (including Uvicorn) by running:
pip install "fastapi[standard]"
What does this command do?
The [standard] extra installs FastAPI along with everything you need to run it in development. This includes Uvicorn (the web server), WebSocket support, and auto-reload for code changes.
You should see pip downloading and installing several packages. The output ends with a success message listing the installed packages.
- Create a new file called main.py inside the backend folder and add this code:
from fastapi import FastAPI
# Create the FastAPI application instance
app = FastAPI(title="Task Dashboard API")
What does this code do?
This creates a new FastAPI application instance. The title parameter sets the name that appears in the auto-generated API documentation.
Two lines of code give you a fully functional web server with interactive docs built in.
- Save main.py.
- Start the FastAPI dev server by running this command in your terminal:
uvicorn main:app --reload
What does this command do?
- uvicorn is the web server that runs your FastAPI app.
- main:app tells Uvicorn to look for the app object inside main.py.
- --reload automatically restarts the server whenever you change your code.
- Open your browser and visit http://localhost:8000/docs.
You should see the Swagger UI page with your API title "Task Dashboard API" at the top. This interactive documentation is auto-generated by FastAPI from your code.
Don't see the Swagger UI page?
Check that your terminal shows a line containing Uvicorn running on http://127.0.0.1:8000 with no error messages. If you see an import error, make sure your virtual environment is active ((venv) in the prompt).
If the browser shows "This site can't be reached", double check that the URL is http://localhost:8000/docs with no typos.
Help me get my FastAPI server running.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and keep the Uvicorn server running in this terminal.
ⓧ I'd like to double check the full code
from fastapi import FastAPI
app = FastAPI(title="Task Dashboard API")
Keep this terminal open with Uvicorn running. You'll open a separate terminal for the frontend next.
Install Node.js and scaffold the React frontend
The React frontend needs Node.js as its runtime. Vite handles the build tooling and dev server, so you'll use it to scaffold a fresh React project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open a new window. Keep the Uvicorn terminal running in the background.
- Check if Node.js is already installed by running:
node --version
✔️ I see version 24 or higher
Node.js is ready. Continue to the next instruction below.
ⓧ I see an older version
You need Node.js v24 or higher. Vite 8 requires Node.js version 20.19+ or 22.12+, and the LTS v24 line is the recommended choice.
Download and install the LTS version from the official site. The installer replaces your current version.
macOS
- Download the macOS installer from nodejs.org/en/download.
- Open the downloaded .pkg file and follow the installation prompts.
Windows
- Download the Windows MSI installer from nodejs.org/en/download.
- Open the downloaded .msi file and follow the installation prompts. Accept the defaults at each step.
- Close and reopen your terminal, then verify the new version by running:
node --version
You should now see v24 or higher.
ⓧ Command not found
Node.js is not installed yet. Download and install the LTS version.
macOS
- Download the macOS installer from nodejs.org/en/download.
- Open the downloaded .pkg file and follow the installation prompts.
Windows
- Download the Windows MSI installer from nodejs.org/en/download.
- Open the downloaded .msi file and follow the installation prompts. Accept the defaults at each step.
- Close and reopen your terminal, then verify by running:
node --version
You should see v24 or higher.
- Navigate to your task-dashboard folder by running:
cd ~/Desktop/task-dashboard
- Scaffold a new React app using Vite by running:
npm create vite@latest frontend -- --template react
What prompts will I see?
npm may ask you to install the create-vite package. Press y then Enter to confirm.
For React templates, the tool may ask which linter to use. Select Oxlint (the default option).
You will also see a prompt asking "Install with npm and start now?". Select No so you can walk through each step yourself.
You should see output confirming the project was scaffolded inside a frontend folder.
Why Vite instead of Next.js?
This project is a single-page app with no routing or server-side rendering needs. Vite + React is the lightest path and keeps you focused on React hooks and WebSocket integration rather than framework configuration.
- Move into the new frontend folder and install dependencies by running:
cd frontend
npm install
You should see npm downloading packages. The output ends with a summary showing the number of packages installed.
- Start the Vite dev server by running:
npm run dev
- Open your browser and visit http://localhost:5173.
You should see the Vite + React starter page with the Vite and React logos and a counter button.
Don't see the React starter page?
Make sure you ran npm install before npm run dev. Without the install step, the project has no dependencies and the dev server cannot start.
Check that port 5173 is not already in use by another application. The terminal output shows the exact URL the server is running on.
Help me get the Vite React dev server running.
Confirm both servers are running
You now have two terminals open. One is running your FastAPI backend, and the other is running your React frontend.
Before you check, what do you expect to see at localhost:8000/docs and localhost:5173?
- Open http://localhost:8000/docs in your browser. You should see the FastAPI Swagger UI with "Task Dashboard API" as the title.
- Open http://localhost:5173 in your browser. You should see the Vite + React starter page with the logos and counter button.
Both pages loading confirms your backend and frontend environments are set up correctly and running on separate ports.
One of the servers stopped?
If the FastAPI server is no longer running, switch to the terminal where it was and check for error messages. Make sure you are in the backend directory with (venv) active, then run:
uvicorn main:app --reload
If the Vite server stopped, switch to the frontend terminal, make sure you are in the frontend directory, and run:
npm run dev
Help me get both servers running at the same time.
Your backend and frontend are both up and running. Next up, you'll build the REST API endpoints that handle task submissions and return task data.
Build the Task API with Pydantic v2
Both your servers are up and running. The FastAPI backend is live on port 8000, and the Vite dev server is serving the React starter page on port 5173. But the API itself is empty.
Before adding any real-time features, you need a working REST API that can accept task submissions and return task data. In this step, you'll build a baby version of the final product: you can submit and retrieve tasks through Swagger UI.
In this step, get ready to:
- Define Pydantic v2 data models for task input and output.
- Build REST endpoints to create and retrieve tasks.
- Add CORS middleware so the React frontend can call the API.
Define the data models and task endpoints
Every API needs a contract: what data can the client send, and what data does the API return? Pydantic models define these contracts so FastAPI can validate requests automatically and generate accurate documentation.
- Switch to the terminal running uvicorn from earlier.
- Open backend/main.py in your code editor.
- Replace the entire contents of the file with the following code:
import uuid
from datetime import datetime
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(title="Task Dashboard API")
# Input schema: what the client sends when creating a task
class TaskCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=500)
# Output schema: what the API returns for any task
class TaskResponse(BaseModel):
id: str
name: str
description: str
status: str
progress: int
result: str | None
created_at: str
# In-memory task storage (dictionary keyed by task ID)
tasks: dict[str, dict] = {}
What does this code do?
TaskCreate is the input schema. It defines the data a client must send when creating a task: a name (1 to 200 characters) and an optional description. FastAPI rejects any request that violates these constraints before your code even runs.
TaskResponse is the output schema. It guarantees every task response includes an id, status, progress, and other fields. Having separate input and output models means the client never needs to send fields the server generates (like id or created_at).
The tasks dictionary stores tasks in memory, keyed by their unique ID. This keeps the project focused on async and WebSocket patterns instead of database setup.
The models and store are ready. Now you need endpoints that use them.
- Add the following code at the bottom of main.py, below the tasks dictionary:
# Create a new task and add it to the store
@app.post("/tasks", response_model=TaskResponse)
async def create_task(task_in: TaskCreate):
task_id = str(uuid.uuid4())
task = {
"id": task_id,
"name": task_in.name,
"description": task_in.description,
"status": "queued",
"progress": 0,
"result": None,
"created_at": datetime.now().isoformat(),
}
tasks[task_id] = task
return task
# Return all tasks as a list
@app.get("/tasks", response_model=list[TaskResponse])
async def list_tasks():
return list(tasks.values())
What does this code do?
POST /tasks creates a new task. It generates a unique ID with uuid.uuid4(), sets the initial status to "queued" with 0 progress, stores it in the dictionary, and returns the full task object.
GET /tasks returns every task in the store as a list. The response_model parameter tells FastAPI to validate the response against TaskResponse and generate accurate API documentation.
- Save main.py. The uvicorn server auto-reloads when you save.
- Open your browser and go to localhost:8000/docs.
- Click on POST /tasks to expand it.
- Click Try it out.
- Replace the request body with this JSON:
{
"name": "Analyze Q3 sales data",
"description": "Review quarterly trends"
}
- Click Execute.
You should see a 200 response with your task data, including a generated id and the status set to "queued".
- Now expand GET /tasks, click Try it out, then Execute.
You should see a list containing the task you just created.
Not seeing your endpoints in Swagger UI?
- Check the terminal running uvicorn for error messages. A syntax error in main.py prevents the server from reloading.
- Make sure you saved the file. The auto-reload only triggers on save.
- Verify your endpoint code is outside the class definitions, at the module level.
Still stuck? Ask for help.
Add CORS middleware and a single-task endpoint
Your React frontend runs on port 5173. Your API runs on port 8000. Browsers block requests between different origins by default. CORS middleware tells FastAPI to accept requests from your frontend's origin.
- In main.py, find this line in your imports:
from fastapi import FastAPI
- Replace it with these two lines:
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
- Now find the line app = FastAPI(title="Task Dashboard API") and add the following code directly below it, above the TaskCreate class:
# Allow the React frontend to make cross-origin requests
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
What does CORS middleware do?
Without CORS middleware, your browser would block every fetch call from the React app (port 5173) to the API (port 8000). The allow_origins list tells FastAPI exactly which origins are allowed. Only http://localhost:5173 is permitted here, keeping the API locked down to your frontend.
Next, add an endpoint to retrieve a single task by its ID. This is how the frontend will fetch details for one specific task.
- Add the following code at the bottom of main.py, below the list_tasks function:
# Retrieve a single task by its ID
@app.get("/tasks/{task_id}", response_model=TaskResponse)
async def get_task(task_id: str):
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
return tasks[task_id]
What does this endpoint do?
GET /tasks/{task_id} looks up a task by its unique ID. If the ID does not exist in the store, it raises an HTTPException with a 404 status code. FastAPI automatically converts this into a proper JSON error response.
- Save main.py.
Seeing an import error in the terminal?
- Make sure your import line reads from fastapi import FastAPI, HTTPException with a comma between the two names.
- Check that the CORSMiddleware import is on its own line: from fastapi.middleware.cors import CORSMiddleware.
Need more help debugging?
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.py and that uvicorn has reloaded without errors.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm these sections are present in order:
- Imports: uuid, datetime, FastAPI, HTTPException, CORSMiddleware, BaseModel, Field.
- App creation + CORS middleware: app = FastAPI(...) followed by app.add_middleware(CORSMiddleware, ...).
- Pydantic models: TaskCreate and TaskResponse classes.
- In-memory store: tasks: dict[str, dict] = {}.
- Three endpoints: POST /tasks, GET /tasks, GET /tasks/{task_id}.
Test the baby version end to end
You now have a complete REST API with three endpoints and validated data models. Time to test the full flow.
- Return to localhost:8000/docs in your browser and refresh the page.
- You should now see three endpoints listed: POST /tasks, GET /tasks, and GET /tasks/{task_id}.
- Use POST /tasks to submit a new task (click Try it out, enter a name, then Execute).
Before you call GET /tasks, predict: what status do you expect the task to have?
- Now expand GET /tasks, click Try it out, then Execute.
Your task comes back with "status": "queued". Wait a few seconds and call GET /tasks again. The status is still "queued". Nothing processes it. That is exactly the gap you will close in the next step.
Why in-memory storage instead of a database?
The goal of this project is to learn async processing and WebSocket streaming. A database would add 15+ minutes of ORM setup without teaching those core concepts. In a production system, you would swap this dictionary for a database like PostgreSQL with async SQLAlchemy.
You have a working REST API with validated data models and three endpoints. Tasks can be created and retrieved, but they are stuck in "queued" forever. Next up, you will add background processing and WebSocket streaming so tasks actually run and broadcast their progress live.
Add Background Processing and WebSocket Streaming
In the last step, you built a working REST API that can create and retrieve tasks. There is one problem though: every task you submit stays stuck in queued status forever, because nothing actually processes them.
This step changes everything. You will build an async task processor that simulates real data analysis work in stages, a ConnectionManager that tracks connected clients, and a WebSocket endpoint that streams live progress updates to every connected browser.
In this step, get ready to:
- Build a ConnectionManager class that manages WebSocket connections and broadcasts messages.
- Create an async task processor that simulates work in stages and streams progress.
- Add a WebSocket endpoint and wire up live progress streaming.
Build the ConnectionManager and task processor
Before tasks can process in the background, you need two new capabilities: a way to manage WebSocket connections, and the asyncio module for async task scheduling. Start by updating the imports at the top of backend/main.py.
- In backend/main.py, find the import lines at the top of the file:
import uuid
from datetime import datetime
from fastapi import FastAPI, HTTPException
- Replace those four lines with the following updated imports:
import asyncio
import uuid
from datetime import datetime
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
What changed in the imports?
You added import asyncio for background task scheduling. You also swapped HTTPException for WebSocket and WebSocketDisconnect, which you need for the real-time streaming endpoint.
Since you removed HTTPException from the top-level import, the get_task function at the bottom of the file will break. Fix it by adding a local import.
- Scroll down to the get_task function and find this line inside the if block:
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
- Add from fastapi import HTTPException right before the raise line, so it looks like this:
if task_id not in tasks:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Task not found")
Now add the ConnectionManager class. This is the central hub that keeps track of every connected WebSocket client and lets your server broadcast messages to all of them at once.
- In backend/main.py, find the line tasks: dict[str, dict] = {} and add the following code below it:
class ConnectionManager:
def __init__(self):
# Track all active WebSocket connections
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
# Accept the connection and add it to the list
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
# Remove a disconnected client
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
# Send a JSON message to every connected client
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
pass
manager = ConnectionManager()
What does this code do?
- active_connections is a list that holds every currently connected WebSocket client.
- connect() accepts a new WebSocket handshake and adds the client to the list.
- broadcast() loops through every connected client and sends the same JSON message to all of them. The try/except prevents one broken connection from crashing the whole broadcast.
- Save backend/main.py.
- Switch back to the terminal running your FastAPI server. If --reload is active, it should restart automatically. Check that the terminal shows no errors and that the server is running on port 8000.
Seeing an import error or crash?
- If you see NameError: name 'HTTPException' is not defined, make sure you added the local from fastapi import HTTPException line inside the get_task function.
- If you see NameError: name 'WebSocket' is not defined, confirm your top-level import reads from fastapi import FastAPI, WebSocket, WebSocketDisconnect.
Still stuck? Help me debug this import error in my FastAPI app.
Next, build the async function that actually processes tasks. This function simulates real data analysis by progressing through four stages, pausing between each one, and broadcasting progress to every connected client.
- In backend/main.py, find the line manager = ConnectionManager() and add the first part of the process_task function below it:
async def process_task(task_id: str):
# Define the stages the task progresses through
stages = [
("validating", 25),
("analyzing", 50),
("summarizing", 75),
("finalizing", 100),
]
# Set the task to running and notify all connected clients
tasks[task_id]["status"] = "running"
await manager.broadcast(
{"task_id": task_id, "status": "running", "progress": 0}
)
# Work through each stage with a 2-second pause
for stage_name, progress in stages:
await asyncio.sleep(2)
# Exit early if the task was cancelled
if tasks[task_id]["status"] == "cancelled":
await manager.broadcast(
{"task_id": task_id, "status": "cancelled", "progress": progress - 25}
)
return
tasks[task_id]["progress"] = progress
await manager.broadcast(
{"task_id": task_id, "status": "running", "progress": progress}
)
What does this code do?
- stages defines four processing phases, each paired with a progress percentage. This simulates real analysis work like validation, data crunching, and report generation.
- asyncio.sleep(2) pauses for 2 seconds between stages. Because it is an async sleep, it does not block the server from handling other requests.
- The cancellation check (if tasks[task_id]["status"] == "cancelled") lets the processor exit early if something flags the task for cancellation between stages.
- manager.broadcast() sends a JSON update to every connected WebSocket client after each stage completes.
- Still inside the process_task function, add the completion logic directly below the for loop. Keep the same indentation level as the for keyword:
# All stages done - mark as completed with a result
tasks[task_id]["status"] = "completed"
tasks[task_id]["progress"] = 100
tasks[task_id]["result"] = f"Analysis complete for '{tasks[task_id]['name']}'. Processed 1,247 records with 98.3% confidence."
await manager.broadcast(
{
"task_id": task_id,
"status": "completed",
"progress": 100,
"result": tasks[task_id]["result"],
}
)
What does this code do?
Once the loop finishes all four stages, this block marks the task as completed and sets a human-readable result string. The final broadcast sends the completed status and result to all connected clients so they see the outcome instantly.
The processor is defined but nothing calls it yet. Wire it up by modifying the create_task endpoint to kick off background processing every time a new task is submitted.
- In the create_task function, find the line that says:
tasks[task_id] = task
return task
- Add asyncio.create_task(process_task(task_id)) between those two lines, so it looks like this:
tasks[task_id] = task
asyncio.create_task(process_task(task_id))
return task
Why asyncio.create_task instead of BackgroundTasks?
FastAPI has a built-in BackgroundTasks feature, but it runs tasks only after the response is sent and cannot broadcast progress while working. asyncio.create_task() launches the processor concurrently so it can stream live updates through WebSocket connections while the task runs.
- Save backend/main.py and wait for uvicorn to auto-reload.
- Open http://localhost:8000/docs in your browser to test with Swagger UI.
- Expand the POST /tasks endpoint, click Try it out, and submit a task with any name.
- Wait about 10 seconds for the four stages to complete (2 seconds each plus overhead).
- Expand the GET /tasks endpoint, click Try it out, then Execute. You should see your task with "status": "completed" and a result string.
Task still showing queued?
- Make sure the asyncio.create_task(process_task(task_id)) line is inside the create_task function, between tasks[task_id] = task and return task.
- Wait at least 10 seconds after submitting before calling GET /tasks. The processor takes about 8 seconds total.
- Check the terminal running uvicorn for any traceback errors.
Need help? Help me debug why my task is stuck in queued status.
Stream live progress over WebSocket
Tasks now process in the background, but you had to manually call GET /tasks to see the result. The ConnectionManager is broadcasting messages, but no clients can connect yet because there is no WebSocket endpoint.
- Add the WebSocket endpoint at the very bottom of backend/main.py, below the get_task function:
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
# Accept the connection and register with the manager
await manager.connect(websocket)
try:
# Keep the connection alive by waiting for messages
while True:
await websocket.receive_text()
except WebSocketDisconnect:
# Clean up when the client disconnects
manager.disconnect(websocket)
What does this endpoint do?
- @app.websocket("/ws") registers a WebSocket route, just like @app.get() registers an HTTP route.
- The while True loop keeps the connection open. Without it, the WebSocket would close immediately after connecting.
- When a client disconnects, WebSocketDisconnect is raised and the manager removes that client from its list so broadcasts skip it.
- Save backend/main.py and confirm uvicorn reloads without errors.
Server crashing after adding the WebSocket endpoint?
- Check that the decorator reads @app.websocket("/ws"), not @app.get("/ws").
- Confirm that WebSocket and WebSocketDisconnect are both in your top-level import from fastapi.
Still stuck? Help me fix my WebSocket endpoint.
✔️ Awesome, I've got everything!
Great. Double check you have saved backend/main.py and that uvicorn is running without errors.
ⓧ I'd like to double check the full code
Your backend/main.py is now too long for a single view. Scroll through your file and confirm these sections appear in order:
- Imports: import asyncio, import uuid, from datetime import datetime, from fastapi import FastAPI, WebSocket, WebSocketDisconnect, the CORS middleware import, and Pydantic imports.
- App and middleware: app = FastAPI(title="Task Dashboard API") followed by the CORS middleware block.
- Models: TaskCreate and TaskResponse Pydantic classes, followed by tasks: dict[str, dict] = {}.
- ConnectionManager: The full class with connect(), disconnect(), and broadcast() methods, followed by manager = ConnectionManager().
- process_task: The full async function with the stages loop and the completion block at the end.
- Endpoints: POST /tasks (with the asyncio.create_task line), GET /tasks, GET /tasks/{task_id} (with the local HTTPException import), and the new @app.websocket("/ws") endpoint at the bottom.
Test the full flow with live WebSocket streaming
Everything is wired up. Time to see live progress streaming in action by connecting a WebSocket client in your browser console while submitting a task through Swagger UI.
Before you test, think about what you expect to happen. When you POST a new task while a WebSocket client is connected, do you think the client will receive one message at the end, or multiple messages as the task progresses?
- Open two browser tabs. In the first tab, open http://localhost:8000/docs (Swagger UI).
- In the second tab, open any page (the Swagger UI page works fine).
- In the second tab, open your browser's developer console. Press Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Connect to the WebSocket endpoint by pasting this into the console and pressing Enter:
const ws = new WebSocket("ws://localhost:8000/ws");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
- Switch to the first tab (Swagger UI).
- Expand POST /tasks, click Try it out, and submit a task with any name.
- Switch back to the second tab and watch the console.
You should see a stream of JSON messages appearing in the console every 2 seconds. Each message shows the task progressing through stages: running at 0%, then 25%, 50%, 75%, 100%, and finally completed with the result string.
What should I see?
Your console output should look something like this:
{"task_id": "abc-123", "status": "running", "progress": 0}
{"task_id": "abc-123", "status": "running", "progress": 25}
{"task_id": "abc-123", "status": "running", "progress": 50}
{"task_id": "abc-123", "status": "running", "progress": 75}
{"task_id": "abc-123", "status": "running", "progress": 100}
{"task_id": "abc-123", "status": "completed", "progress": 100, "result": "Analysis complete for ..."}
The task_id will be a unique UUID, not abc-123. The key point is that you see multiple messages arriving over time, not all at once.
Not seeing any messages in the console?
- Make sure you pasted the WebSocket connection code and pressed Enter before submitting the task. The WebSocket must be connected first to receive messages.
- Check for a connection error in the console. If you see a failed connection, confirm uvicorn is running on port 8000.
- Confirm the WebSocket URL uses ws:// (not http://).
Still stuck? Help me debug my WebSocket connection to FastAPI.
Tasks now process asynchronously and stream live progress to connected clients. The backend is feature-complete for real-time updates. Next up, you will build the React dashboard so users can submit tasks and see results through a proper UI instead of Swagger UI and browser consoles.
Build the React Dashboard
Your API backend processes tasks asynchronously and streams live progress over WebSockets. But you've been testing everything through Swagger UI, which is a developer tool, not a user interface.
Real users need a proper visual interface. In this step, you'll build a React dashboard with a task submission form and a task list that displays each task's status with color-coded badges.
In this step, get ready to:
- Build a task submission form that sends new tasks to the API.
- Render a task list with color-coded status badges.
Create the App component with task logic
The React frontend communicates with the FastAPI backend through HTTP requests using the fetch API. You'll manage form inputs and the task list with React's useState and useEffect hooks.
- Open frontend/src/App.jsx in your code editor.
- Select all the existing Vite boilerplate content and delete it.
- Start building the new component by pasting the following code at the top of the empty file:
import { useState, useEffect } from "react";
const API_URL = "http://localhost:8000";
function App() {
const [taskName, setTaskName] = useState("");
const [taskDesc, setTaskDesc] = useState("");
const [tasks, setTasks] = useState([]);
// Fetch existing tasks when the component first loads
useEffect(() => {
fetch(`${API_URL}/tasks`)
.then((res) => res.json())
.then(setTasks)
.catch(console.error);
}, []);
// Send a new task to the API and add it to the list
const handleSubmit = async (e) => {
e.preventDefault();
if (!taskName.trim()) return;
const res = await fetch(`${API_URL}/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: taskName, description: taskDesc }),
});
const newTask = await res.json();
setTasks((prev) => [newTask, ...prev]);
setTaskName("");
setTaskDesc("");
};
What does this code do?
- API_URL stores the backend address so every fetch call points to the right place.
- Three useState calls create state for the form inputs (taskName, taskDesc) and the task list (tasks).
- useEffect runs once when the component loads and fetches all existing tasks from GET /tasks.
- handleSubmit sends a POST request to create a new task, then prepends it to the tasks array so it appears at the top of the list.
- Continue in the same file by pasting this helper function directly below the closing }; of handleSubmit:
// Map each task status to a display color
const statusColor = (status) => {
switch (status) {
case "queued": return "#6b7280";
case "running": return "#2563eb";
case "completed": return "#16a34a";
case "cancelled": return "#dc2626";
default: return "#6b7280";
}
};
What does statusColor do?
statusColor maps each task status string to a hex color code. The dashboard uses these colors for the status badges on each task card: grey for queued, blue for running, green for completed, red for cancelled.
Add the dashboard layout and task cards
Now you'll add the JSX that renders the actual UI. The layout uses a flex row with the submission form fixed at 280px on the left and the task list filling the remaining space on the right.
- Continue in App.jsx by pasting the form layout directly below the statusColor function:
return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: 24, fontFamily: "system-ui, sans-serif" }}>
<header style={{ marginBottom: 32 }}>
<h1 style={{ fontSize: 28, marginBottom: 4 }}>Task Dashboard</h1>
<p style={{ color: "#6b7280" }}>Real-time task processing</p>
</header>
<div style={{ display: "flex", gap: 32, alignItems: "flex-start" }}>
<form onSubmit={handleSubmit} style={{ flex: "0 0 280px" }}>
<h2 style={{ fontSize: 18, marginBottom: 12 }}>New Task</h2>
<input
type="text"
placeholder="Task name"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
style={{ width: "100%", padding: 8, marginBottom: 8, border: "1px solid #d1d5db", borderRadius: 4, boxSizing: "border-box" }}
/>
<textarea
placeholder="Description (optional)"
value={taskDesc}
onChange={(e) => setTaskDesc(e.target.value)}
rows={3}
style={{ width: "100%", padding: 8, marginBottom: 8, border: "1px solid #d1d5db", borderRadius: 4, boxSizing: "border-box", resize: "vertical" }}
/>
<button
type="submit"
style={{ width: "100%", padding: 10, background: "#2563eb", color: "white", border: "none", borderRadius: 4, cursor: "pointer", fontWeight: 600 }}
>
Submit Task
</button>
</form>
What does the form render?
- The outer <div> centers the dashboard at a max width of 900px.
- The <header> displays the dashboard title and subtitle.
- The <form> calls handleSubmit when the learner clicks Submit Task. Each input is wired to its corresponding state variable so React tracks what gets typed.
- Complete the component by pasting the task list directly below </form>:
{/* Task list - fills remaining space */}
<div style={{ flex: 1 }}>
<h2 style={{ fontSize: 18, marginBottom: 12 }}>Tasks</h2>
{tasks.length === 0 && <p style={{ color: "#9ca3af" }}>No tasks yet. Submit one to get started.</p>}
{tasks.map((task) => (
<div key={task.id} style={{ border: "1px solid #e5e7eb", borderRadius: 8, padding: 16, marginBottom: 12, background: task.status === "cancelled" ? "#fef2f2" : "white" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<strong style={{ textDecoration: task.status === "cancelled" ? "line-through" : "none" }}>
{task.name}
</strong>
<span style={{ color: "white", background: statusColor(task.status), padding: "2px 10px", borderRadius: 12, fontSize: 13, fontWeight: 600 }}>
{task.status}
</span>
</div>
{task.result && (
<p style={{ fontSize: 14, color: "#374151", margin: 0 }}>{task.result}</p>
)}
</div>
))}
</div>
</div>
</div>
);
}
export default App;
What does the task list render?
- When tasks is empty, a placeholder message appears.
- Each task renders as a card showing the task name and a color-coded status badge powered by statusColor.
- If a task has a result, it displays below the task name.
- export default App at the bottom makes this component available to main.jsx.
- Save App.jsx.
- Switch to your browser and check localhost:5173.
The Vite starter page is gone. You now see your Task Dashboard with a submission form on the left and an empty task list that reads "No tasks yet" on the right.
- Enter Quarterly Report in the task name field.
- Click Submit Task.
The task appears in the list with a grey "queued" status badge.
Don't see the dashboard?
- Make sure the Vite dev server is still running in its terminal. If it stopped, run npm run dev again from the frontend folder.
- Open your browser's developer console and look for errors. A Failed to fetch error means the FastAPI server on port 8000 is not running.
- Confirm your file ends with export default App;. Without this line, main.jsx cannot find the component.
- My React dashboard is not loading at localhost:5173. How do I debug this?
Discover the refresh problem
You submitted a task and it appeared as "queued". The backend is processing it right now, moving it through its stages. Before you do anything, predict: will the status badge update on its own, or will it stay stuck on "queued"?
- Wait about 10 seconds without touching the page.
- Now refresh the page manually by pressing Cmd+R (macOS) or Ctrl+R (Windows).
After refreshing, the status badge turns green and reads "completed". The result text appears below the task name.
The dashboard works, but you had to refresh the page to see the update. The backend was streaming progress over WebSockets the whole time, but nothing in the frontend was listening. That stale-data gap is exactly what the next step fixes.
✔️ Awesome, I've got everything!
Great. Double check you've saved App.jsx and that your dashboard loads at localhost:5173.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these code sections is present in your App.jsx, in this order:
- Imports, API_URL, state declarations, useEffect, and handleSubmit (the first code block you pasted).
- The statusColor helper function with a switch statement (the second code block).
- The return statement with the header and form containing an input, textarea, and Submit Task button (the third code block).
- The task list <div> with tasks.map, closing tags, and export default App; (the fourth code block).
The backend/main.py and frontend/src/main.jsx files are unchanged from the previous step.
Your dashboard is functional. Users can submit tasks and see results. But that manual refresh is a dealbreaker for a real-time system. Next up, you'll wire the React app to the WebSocket endpoint so progress streams directly into the UI.
Wire Up WebSocket for Live Updates
Your dashboard works, but there is a glaring problem. Every time you submit a task, you have to manually refresh the browser to see whether it finished. That defeats the entire purpose of a real-time dashboard.
In this step, you will connect the React frontend to the WebSocket endpoint your backend already exposes at /ws. Task progress will stream directly into the UI. Progress bars will animate, statuses will flip live, and results will appear the moment processing finishes.
In this step, get ready to:
- Create a custom React hook that manages the WebSocket connection lifecycle.
- Integrate the hook into the dashboard so tasks update live without page refreshes.
Create the WebSocket hook
A custom hook keeps all WebSocket logic in one reusable place. It will open a connection, parse incoming messages, reconnect automatically if the connection drops, and expose a connected flag the UI can display.
- In your code editor, create a new file called useTaskWebSocket.js inside the frontend/src folder (the same folder that contains App.jsx).
- Paste the following code into useTaskWebSocket.js to set up the hook signature, state, and the core connection logic:
import { useState, useEffect, useRef, useCallback } from "react";
export function useTaskWebSocket(url, onMessage) {
// Track connection status for the UI indicator
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
// Stable ref avoids stale closures when callback changes
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
};
// Parse incoming JSON and forward to message handler
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessageRef.current(data);
} catch (err) {
console.error("Failed to parse WebSocket message:", err);
}
};
What does this code do?
The hook takes a WebSocket URL and an onMessage callback. It stores the callback in a ref (onMessageRef) so the WebSocket always calls the latest version without needing to reopen the connection when the callback changes.
When a message arrives, ws.onmessage parses the JSON and passes the data object to your handler. The useCallback wrapper ensures the connect function stays stable across renders.
- Still in useTaskWebSocket.js, add the remaining connection handlers and the lifecycle effect directly below the code you just pasted:
// Auto-reconnect after 3 seconds if connection drops
ws.onclose = () => {
setConnected(false);
setTimeout(() => connect(), 3000);
};
ws.onerror = () => {
ws.close();
};
}, [url]);
// Connect on mount, clean up on unmount
useEffect(() => {
connect();
return () => {
if (wsRef.current) {
// Null onclose to prevent reconnect loop during cleanup
wsRef.current.onclose = null;
wsRef.current.close();
}
};
}, [connect]);
return { connected };
}
What does this code do?
- ws.onclose sets connected to false and schedules a reconnect attempt after 3 seconds. If your backend restarts, the frontend reconnects automatically.
- ws.onerror closes the socket, which triggers onclose and the reconnect cycle.
- The cleanup function in useEffect nulls out onclose before closing the socket. Without that, closing would trigger the reconnect timer during unmount.
- Save useTaskWebSocket.js.
Why a custom hook instead of inline WebSocket code?
Putting all WebSocket logic inside App.jsx would work, but the component would mix UI concerns with connection management. A custom hook isolates lifecycle handling (open, reconnect, cleanup) so App.jsx only needs to call useTaskWebSocket() and react to messages.
✔️ Awesome, I've got everything!
Great. Make sure you have saved useTaskWebSocket.js. The hook is ready but nothing uses it yet. The next substep wires it into the dashboard.
ⓧ I'd like to double check the full code
Scroll up and confirm you have both chunks in useTaskWebSocket.js, in this order:
- The import line, the useTaskWebSocket function declaration, state and ref setup, and the connect callback through the ws.onmessage handler.
- The ws.onclose and ws.onerror handlers, the closing of the useCallback, the useEffect lifecycle block, and return { connected }.
Integrate the hook into the dashboard
The hook exists, but App.jsx does not import or call it yet. You need to wire it in, define what happens when a WebSocket message arrives, and give the user a visible connection indicator in the header.
- In App.jsx, find the import line at the top of the file:
import { useState, useEffect } from "react";
- Add this new import line directly below it:
import { useTaskWebSocket } from "./useTaskWebSocket";
- Now scroll down inside the App function and find this line:
const [tasks, setTasks] = useState([]);
- Add the following code directly below that line, before the useEffect call:
// When a WebSocket message arrives, update the matching task
const onMessage = (data) => {
setTasks((prev) =>
prev.map((t) =>
t.id === data.task_id
? {
...t,
status: data.status,
progress: data.progress,
result: data.result || t.result,
}
: t
)
);
};
// Connect to the backend WebSocket
const { connected } = useTaskWebSocket("ws://localhost:8000/ws", onMessage);
What does onMessage do?
Every time the backend broadcasts a progress update, this callback receives it. It maps over the current task list and finds the task whose id matches data.task_id, then merges in the new status, progress, and result. React re-renders the list instantly.
Now you need to show the connection status in the header so the user knows the WebSocket is active.
- In the JSX returned by App, find this line inside the <header>:
<p style={{ color: "#6b7280" }}>Real-time task processing</p>
- Replace that line with this version that includes a connection indicator:
<p style={{ color: "#6b7280" }}>
Real-time task processing
<span style={{ marginLeft: 12, color: connected ? "#16a34a" : "#dc2626" }}>
{connected ? "Connected" : "Disconnected"}
</span>
</p>
- Save App.jsx.
- Switch to your browser at localhost:5173. Next to "Real-time task processing" in the header, you should see the word Connected in green.
Seeing Disconnected in red?
- Make sure your FastAPI backend is still running in its terminal. The WebSocket endpoint at /ws lives on the backend server.
- Check that the URL in your hook call is exactly ws://localhost:8000/ws. A typo like http:// instead of ws:// will fail silently.
- Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for WebSocket errors.
Why is my WebSocket connection showing Disconnected even though my backend is running?
The connection is live, but the task list still looks the same as before. You need to add a progress bar that animates when a task is running.
- In App.jsx, scroll down to the task card rendering inside tasks.map(). Find this line:
{task.result && (
- Add the following progress bar code directly above that line:
{/* Animated progress bar for running tasks */}
{task.status === "running" && (
<div style={{ background: "#e5e7eb", borderRadius: 4, height: 8, marginBottom: 8 }}>
<div
style={{
background: "#2563eb",
height: "100%",
borderRadius: 4,
width: `${task.progress}%`,
transition: "width 0.5s ease",
}}
/>
</div>
)}
What does this code do?
This renders a thin bar only when the task status is running. The outer div is the grey track. The inner div is the blue fill, sized by task.progress (0 to 100%). The CSS transition: "width 0.5s ease" makes the bar animate smoothly between progress updates instead of jumping.
- Save App.jsx.
Before you test, think about what you expect to see. When you submit a new task, do you think you will need to refresh the page to see the status change?
- Switch to your browser at localhost:5173.
- Type a task name (for example, Analyze Q3 sales) and click Submit Task.
Without refreshing, you should see the task appear as "queued", transition to "running" with a blue progress bar filling in real time, then flip to "completed" with the result text displayed inline. The entire sequence takes about 8 seconds.
- Submit a second task while the first is still running. Both tasks should progress simultaneously, each with its own progress bar.
Tasks still stuck on queued?
- Check your browser console for errors. A common cause is a mismatch between data.task_id in the onMessage callback and t.id in the map. Make sure you wrote data.task_id (with an underscore), not data.taskId.
- Verify that the header still says Connected. If it says Disconnected, the WebSocket is not receiving messages at all.
- Restart both servers: stop them with Ctrl+C in each terminal, then start them again.
My task submissions appear but never progress past queued. How do I debug my WebSocket integration?
✔️ Awesome, I've got everything!
Great. Make sure you have saved both useTaskWebSocket.js and App.jsx.
ⓧ I'd like to double check the full code
Both files are longer than 30 lines, so review them section by section.
For useTaskWebSocket.js, confirm you have:
- The import from react with useState, useEffect, useRef, useCallback.
- The exported useTaskWebSocket function with connected state, wsRef, and onMessageRef.
- The connect function inside useCallback with handlers for onopen, onmessage, onclose (with 3-second reconnect), and onerror.
- A useEffect that calls connect() and cleans up by nulling onclose before closing.
For App.jsx, confirm you have:
- The useTaskWebSocket import on line 2.
- The onMessage callback and useTaskWebSocket hook call between the state declarations and the useEffect.
- The header <p> with the Connected/Disconnected span.
- The progress bar block inside the task card, before {task.result && (.
Your dashboard now streams live updates over WebSockets. No more manual refreshing. You can watch tasks progress in real time with animated progress bars, see statuses flip from queued to running to completed, and read results the moment they appear. This is the pattern production job-processing systems use to keep users informed. Next up, take on an optional Secret Mission to push your skills further.
Secret mission
Add Task Cancellation
Your dashboard can submit and track tasks in real time. But what happens when you fire off a task by mistake, or the data is wrong? Real job-processing systems always give users a way to abort. In this secret mission, you will add a Cancel button that stops a running task mid-flight and updates the UI instantly through the WebSocket connection you already built.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop (includes backend code, frontend code, Python virtual environment, and installed packages).
- Node.js installation (if you installed it during this project).
- Running dev servers (uvicorn on port 8000 and Vite on port 5173).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with your task dashboard.
- Your project files stay on your Desktop.
- Both dev servers continue running in their terminal windows.
- Everything is local, so nothing is costing you money.
Pause - I'll come back to this later
Shut down running processes to free up memory and terminal windows. Your project files stay on your machine so you can pick up where you left off.
- Switch to the terminal running uvicorn and press Ctrl+C to stop the FastAPI server.
- Switch to the terminal running npm run dev and press Ctrl+C to stop the Vite dev server.
- To resume later, open two terminals in your project folder, activate your Python virtual environment, and run uvicorn main:app --reload in one and npm run dev in the other.
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes the project folder, including the Python virtual environment and all installed packages.
- Stop both dev servers by pressing Ctrl+C in each terminal window.
- Delete the project folder from your Desktop. On macOS or Linux, run this command (replacing the folder name with your actual project folder):
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- On Windows, run this command instead:
cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]
- If you installed Node.js specifically for this project and no longer need it, you can uninstall it on Windows by opening Settings, selecting Apps, then Installed apps, searching for Node.js, and clicking Uninstall.
- On macOS, if you installed Node.js through a package manager like Homebrew, run the corresponding uninstall command for that package manager.
Nice Work!
Nice Work!
Nice work. You just built a full-stack real-time task processing dashboard with a FastAPI backend and a React frontend, connected by live WebSocket streaming.
You've learned how to:
- Built a FastAPI backend with REST endpoints, Pydantic v2 validation, and async background task processing using asyncio.create_task.
- Created a WebSocket endpoint with a ConnectionManager that broadcasts real-time progress updates to every connected client.
- Delivered a live-updating React 19 dashboard with a custom useTaskWebSocket hook, animated progress bars, and instant status updates driven by WebSocket events.
- Secret Mission: Added task cancellation with a graceful async shutdown and instant UI feedback through WebSocket broadcast.
Ready to quiz yourself?