
Background tasks for FastAPI apps that need more than BackgroundTasks and less than Celery.
If you have used APScheduler inside a FastAPI app, you have probably met one of these problems:
- A task runs too long and the user wants to cancel it, but there is no clean way to signal the worker while it runs.
- A background job must send progress to a websocket, and you write
run_coroutine_threadsafecode by hand to get back onto the main loop. - You want a job id on every log line of one run, and you pass that id through every call by hand.
- You have a complete async pipeline to run in the background, and you wrap it in
asyncio.runto give it to a scheduler that accepts sync code only.
quiv was built inside Trailarr, a FastAPI app that left APScheduler for these reasons. It is a scheduler for one process, backed by a thread pool. It has three things built in: cooperative cancellation through stop_event, progress callbacks that run on the main loop through progress_hook, and a job id for tracing through job_id.
quiv does not replace Celery. If you need workers in several processes, a queue that survives a restart, or work spread over machines, use Celery or arq. Use quiv when the work belongs inside your own process, where those tools would be far more than you need.
Supports Python 3.10 through 3.14.
Install
uv add quiv
pip install quiv
Quick example
A full FastAPI integration — lifespan startup, an endpoint that schedules work, and progress streaming back to the main loop:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from quiv import Quiv
# Create the Quiv scheduler
scheduler = Quiv(timezone="UTC")
# Wire it up in FastAPI's lifespan so that it starts and dies with your app
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
scheduler.start()
yield
# Shutdown
scheduler.shutdown()
# Create FastAPI app
app = FastAPI(lifespan=lifespan)
# Create a test function that we can later schedule to broadcast progress
# sync/async - doesn't matter; quiv handles them all
def ping(progress_hook=None):
for i in range(30):
# do some work
if progress_hook:
progress_hook(message="ping", progress=i, total=30)
# Now the actual progress callback function that we want to run on the main asyncio loop
async def on_progress(**payload):
# Replace with websocket broadcast, logging, metrics, etc.
print("progress", payload)
# Create the endpoint function that will schedule the task when triggered
@app.post("/start-heartbeat")
def start_heartbeat():
task_id = scheduler.add_task(
task_name="heartbeat",
func=ping,
interval=30,
progress_callback=on_progress,
)
return {"task_id": task_id}
What you actually get
Run async handlers natively, no asyncio.run wrapper
APScheduler integrates with asyncio, but an async pipeline still needs a wrapper or a bridge when you schedule it from a thread pool. quiv accepts an async handler as it is. Each invocation runs in an event loop that quiv creates on the worker thread of that job. Sync and async handlers live in the same scheduler.
async def fetch_updates(stop_event=None):
await some_async_api_call()
scheduler.add_task(task_name="fetch", func=fetch_updates, interval=60)
Cancel a running task from an HTTP endpoint
stop_event is a threading.Event for one job, which quiv injects into your handler. Check it at the natural breakpoints and return early when an endpoint calls scheduler.cancel_job(job_id). quiv kills no thread, and raises no exception across a thread boundary.
def download(media_id: int, stop_event=None):
for chunk in stream_chunks(media_id):
if stop_event and stop_event.is_set():
return # cooperative exit
write(chunk)
Send progress to a websocket, without writing run_coroutine_threadsafe yourself
Your handler calls progress_hook(**payload) inside the thread pool. quiv runs the async callback that you registered on the main asyncio loop. There it can send a websocket message, change the state of the application, or report a metric.
async def on_progress(**payload):
await websocket_manager.broadcast(payload) # runs on the main loop
scheduler.add_task(
task_name="download",
func=download,
progress_callback=on_progress,
run_once=True,
)
Dispatch to the main loop from anywhere — no parameter threading
A function deep inside a task sometimes needs a resource that lives on the main event loop, such as a WebSocket manager that holds the connected clients. Use quiv.run_on_main for that. Import it at module level and call it. quiv finds the active instance, finds its main loop, and sends your callable there. You pass no progress_hook through every layer, and you write no run_coroutine_threadsafe by hand.
from quiv import run_on_main
async def broadcast(payload: dict):
await ws_manager.broadcast(payload) # lives on uvicorn's loop
def deeply_nested_step():
# Inside a Quiv task — but the broadcast hops to the main loop.
run_on_main(broadcast, {"event": "step_done"})
The same run_on_main call also works when invoked from a FastAPI route handler on the main loop — it detects that the caller is already on the main loop and schedules the work there directly (sync targets run inline, async targets are scheduled with create_task) instead of doing a cross-thread hop. One helper, two contexts. When the handler must wait for the result, call_on_main is the same call that returns it.
Correlate logs for one job, across threads
Every invocation gets a job_id, a UUID. Put it into a LoggerAdapter, or into a ContextVar, and every log line of that run carries the same trace id. You can then filter the logs of one job with a single query, while many tasks run at the same time.
import logging
base_logger = logging.getLogger(__name__)
def download_trailer(media_id: int, job_id: str | None = None, stop_event=None):
logger = logging.LoggerAdapter(base_logger, {"trace_id": job_id})
logger.info("Starting download for media %s", media_id)
# every log line through `logger` below carries trace_id=<job_id>
Trailarr uses the ContextVar form of this in production, so that the modules it calls read the trace id without any extra code. See Getting Started for that version.
Concepts
- Task: scheduling definition (
interval,run_once, args/kwargs, status) - Job: one execution record of a task
- Task statuses:
active,running,paused - Job statuses:
scheduled,running,completed,cancelled,failed
How quiv compares
The table describes the default behavior of each tool. All four can be stretched further with extra work.
| quiv | FastAPI BackgroundTasks |
APScheduler | Celery | |
|---|---|---|---|---|
| Runs inside your process | yes | yes | yes | no — separate worker processes |
| Recurring schedules | interval only | no | interval and cron | interval and cron, through beat |
| Cooperative cancellation of a running job | yes, through stop_event |
no | no | partial — revoke reaches a queued task, and terminating a running one kills the worker |
| Progress updates on the main event loop | yes, through progress_hook |
not needed — the task already runs there | no | no |
| Retries with backoff | yes | no | no | yes |
| Per-task timeout | yes, cooperative | no | no | yes, soft and hard limits |
| State survives a restart | no | no | optional, through a job store | yes, through the broker and the result backend |
| Spreads work over processes or machines | no | no | no | yes |
Read the last three rows first. quiv keeps no state across a restart, runs no cron expression, and spreads no work beyond one process. If you need any of those, use APScheduler or Celery. quiv is for the case where a job must run in your process, report progress to your event loop, and stop when a user asks it to.
Before the 1.0.0 release quiv ran for 24 hours under a mixed workload — recurring, async, failing, cancelled, timing out, and sub-second tasks at once. It finished 294,400 jobs with the thread count unchanged, the retained job history flat after the first hour, and memory steady at 53 MB. The log is in the repository at benchmarks/results/soak-24h-2026-09-17.log.
Important caveats
- A temporary database: each
Quivinstance creates a temporary SQLite file, andshutdown()deletes it. The state of your tasks and jobs does not survive a restart. - One process: the scheduler runs inside your process. It is not built to spread work over several processes or machines.
- Picklable arguments: quiv serializes the
argsandkwargsofadd_task()with pickle, to store them. Pickle accepts most Python objects, but it cannot accept a lambda or an inner function. The temporary SQLite database holds trusted internal state: only your application writes to it, andshutdown()deletes it. Never let untrusted input reach that database file.
Next pages
Read the full documentation here:
- Getting Started — install, scheduler setup, and your first task
- API — the full reference for
Quiv,add_task, and every other method - Architecture — how the scheduler, persistence, and execution layers fit together
- Running on the main event loop — dispatch work to the main loop from anywhere in a task's call stack
- Event Listeners — react to what happens to a task and to a job
- Exceptions — the
QuivErrorhierarchy and when each is raised - Testing — patterns for testing handlers and the scheduler in your suite
- AI Tools — bundled agent guide, llms.txt, and the Claude Code plugin
Ideas, bugs, and contributions
quiv started from one app's needs, so the best way it gets better is when other people's apps push it in new directions. If you have a use case it doesn't cover, a rough edge it should smooth out, or a PR you'd like to land — all welcome.
- Open an issue for bugs or feature requests
- Start a discussion if you'd like to talk through an idea first
- PRs are welcome — for anything non-trivial, opening an issue first is usually the fastest path
And if quiv saved you some time, a GitHub star is a nice way to let us know it was useful.