Managing Long-Running Server Work with Reflex Background Tasks
Learn how to implement @rx.event(background=True) in Reflex to handle long-running server tasks without blocking the UI, including state safety and failure modes.
01 Jul 2026, 02:51 UTC

The Problem: Event Loop Blocking
In a Reflex application, event handlers execute on the server. If a handler performs a synchronous, time-consuming operation—such as a heavy API call, a large database query, or file processing—it blocks the server's event loop. This prevents other events from being processed and leaves the user interface frozen until the operation completes.
The Solution: Background Event Handlers
The primary takeaway for handling long-running work is to decouple the initial user trigger from the execution. Use a standard event handler to validate input and initiate the process, then delegate the actual work to a background task using the @rx.event(background=True) decorator. To ensure the UI updates in real-time and avoids race conditions, all state mutations within that background task must occur inside an async with self block.
Architecture Requirements
- Reflex Version: A version supporting
@rx.event(background=True). Because Reflex evolved rapidly from Pynecone, verify the decorator signature in your current version's documentation. - Async Environment: Python 3.9+ to support the required
asynciopatterns. - WebSocket Connectivity: A stable connection between the browser and server to receive state updates pushed by the background task.
Minimal Suitable Design
A robust implementation requires three distinct components: a state subclass for tracking, a trigger handler for validation, and the background worker for execution.
Implementation Example
import reflex as rx
import asyncio
class JobState(rx.State):
status: str = "idle"
progress: int = 0
is_running: bool = False
@rx.event
def start_process(self, user_input: str):
# 1. Trust Boundary: Validate client-provided data
if not user_input or len(user_input) > 100:
return rx.window_alert("Invalid input length")
# 2. Operational Check: Prevent duplicate concurrent tasks
if self.is_running:
return rx.window_alert("A task is already in progress")
self.is_running = True
self.status = "starting"
self.progress = 0
# Trigger the background task
return JobState.run_heavy_work(user_input)
@rx.event(background=True)
async def run_heavy_work(self, user_input: str):
try:
# Simulate multi-step work
for i in range(1, 6):
await asyncio.sleep(1)
# State mutation must be wrapped in 'async with self'
async with self:
self.progress = i * 20
self.status = f"Processing step {i}..."
async with self:
self.status = "completed"
except Exception as e:
# Failure Mode: Handle exceptions to avoid silent stalls
async with self:
self.status = f"Error: {str(e)}"
finally:
async with self:
self.is_running = False
# App definition
app = rx.App()
app.add_page(rx.vstack(
rx.input(on_blur=JobState.set_user_input),
rx.button("Start Work", on_click=JobState.start_process),
rx.text(f"Status: {JobState.status}"),
rx.progress(value=JobState.progress),
))
To run this, execute reflex run in your project directory. Ensure your environment has the necessary permissions to bind to the default port (usually 3000).
Trust and Data Boundaries
Because Reflex compiles to a React frontend, the browser only sends event names and arguments. The logic resides entirely on the server. However, the arguments passed to start_process are client-controlled. You must treat these as untrusted input and validate them on the server before passing them into the background task to prevent injection attacks or resource exhaustion.
Operational Checks and Failure Modes
Operational Checks
- Concurrency Guard: Use a boolean flag (e.g.,
is_running) to prevent users from spawning multiple background tasks by clicking a button repeatedly. - State Synchronization: Verify that
async with selfis used for every update. Mutations outside this block may not be propagated to the client. - Logging: Implement server-side logging within the
exceptblock of the background task to capture failures that the user might not see.
Failure Modes
| Scenario | Effect | Mitigation |
|---|---|---|
| Unhandled Exception | Task terminates; UI stays in "running" state. | Wrap task logic in try...except...finally. |
| WebSocket Drop | Client misses intermediate progress updates. | Reflex syncs state upon reconnection. |
| Server Restart | In-memory state and active tasks are lost. | Use external persistence for critical jobs. |
When to Change the Design
The @rx.event(background=True) pattern is suitable for tasks lasting seconds or a few minutes. You should migrate to an external task queue (such as Celery or RQ) if:
- Durability is required: Tasks must survive a server restart.
- Horizontal Scaling: You are running multiple server workers; in-memory state is local to a single process.
- Resource Intensity: The work is CPU-bound or requires dedicated hardware (GPUs) that should not compete with the web server.
In these cases, the Reflex background task should only serve as a producer that enqueues a job and then polls a database or cache for the result.
Practical Verification
- Deploy the example code and trigger the task. Verify the progress bar moves while the page remains interactive.
- Rapidly click the trigger button; verify the
is_runningguard prevents multiple tasks. - Manually trigger an exception in the code; verify the UI updates to the "Error" status rather than hanging indefinitely.
Limitations
Background tasks are tied to the process lifetime. If the server crashes or is redeployed, all active background tasks are killed instantly. For production-grade reliability, store job IDs in a database and use a persistent worker system.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.