Publishing events from Flask routes and handling them in subscribers with Encore, RQ, Celery or Blinker signals, with idempotent handlers, retries and a broker that runs locally.
Flask routes often kick off work that should not hold up the response, such as welcome emails, CRM syncs or webhooks to partners. Moving it out brings a broker, a worker process, retries and the duplicate deliveries that come with them.
This guide adds Pub/Sub to a Flask signup route with Encore, RQ, Celery and Flask's Blinker signals. Each version publishes a user-signed-up event to a welcome email handler.
What you need to decide:
Encore combines topics and subscriptions in your Flask code with the infrastructure behind them, delivering messages locally under encore run and running on SNS and SQS on AWS or Pub/Sub on GCP in your own account after deploy. It is an infrastructure SDK added to an existing app, and the routes and blueprints stay as they are.

The event is a Pydantic model, and the topic is typed with it:
from encore import pubsub
from pydantic import BaseModel
class UserSignedUp(BaseModel):
user_id: str
email: str
# SNS and SQS on AWS, Pub/Sub on GCP, an in-process emulator under `encore run`.
signups = pubsub.Topic[UserSignedUp]("user-signed-up")
The subscriber skips users it already handled, because delivery is at least once. The set stands in for a table with a unique key, the idempotent receiver pattern:
welcomed: set[str] = set() # Stand-in for a table with a unique key.
async def send_welcome(msg: pubsub.Message[UserSignedUp]) -> None:
# Delivery is at-least-once, so skip users that were already handled.
if msg.data.user_id in welcomed:
return
welcomed.add(msg.data.user_id)
print(f"welcome email to {msg.data.email} (attempt {msg.delivery_attempt})")
welcome = pubsub.Subscription[UserSignedUp](
signups, "send-welcome", handler=send_welcome, max_retries=5
)
The route is an async Flask view that publishes and returns 202:
from flask import Flask, request
flask_app = Flask(__name__)
@flask_app.post("/signup")
async def signup():
data = request.get_json()
msg_id = await signups.publish(UserSignedUp(user_id=data["user_id"], email=data["email"]))
return {"queued": msg_id}, 202
Subscriptions need a running event loop, so Flask is served under Uvicorn through asgiref's WsgiToAsgi, and a lifespan hook calls encore.start():
import os
import encore
import uvicorn
from asgiref.wsgi import WsgiToAsgi
wsgi = WsgiToAsgi(flask_app)
async def app(scope, receive, send):
# The lifespan hook starts Encore's subscription handlers on Uvicorn's loop.
if scope["type"] == "lifespan":
while True:
message = await receive()
if message["type"] == "lifespan.startup":
await encore.start()
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"})
return
else:
await wsgi(scope, receive, send)
if __name__ == "__main__":
host, _, port = os.environ.get("ENCORE_LISTEN_ADDR", "0.0.0.0:8000").rpartition(":")
uvicorn.run(app, host=host or "0.0.0.0", port=int(port))
Under encore run, a signup is delivered to the handler, and posting the same user again logs nothing new:
On deploy, Encore creates an SNS topic and an SQS queue per subscription on AWS, or a Pub/Sub topic and subscription on GCP, in your own account, and provisions an IAM role for the service. Local traces follow each signup from the route into the handler.
An agent adding a second consumer, such as a CRM sync, writes one more Subscription on the same topic and checks the delivery in the local trace.
Publishing after the database write means a crash between the two can drop the event. When that matters, write the event to an outbox table in the same transaction and publish from there. Failed handlers are retried with backoff up to max_retries, so raise an exception for errors worth retrying.
RQ keeps jobs in Redis and runs them in rq worker processes:
from flask import Flask, request
from redis import Redis
from rq import Queue, Retry
app = Flask(__name__)
queue = Queue(connection=Redis.from_url("redis://localhost:6379"))
def send_welcome(user_id: str, email: str) -> None:
print(f"welcome email to {email}")
@app.post("/signup")
def signup():
data = request.get_json()
queue.enqueue(send_welcome, data["user_id"], data["email"], retry=Retry(max=5))
return {"queued": True}, 202
Each consumer is its own job, so a second reaction to a signup means a second enqueue in the route. You run Redis and workers in every environment, and the worker imports the job function by path, so it needs the same code.
Celery is the common choice in larger Flask apps, with Redis or RabbitMQ as the broker:
from celery import Celery
celery = Celery("accounts", broker="redis://localhost:6379/0")
@celery.task(acks_late=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=5)
def send_welcome(user_id: str, email: str) -> None:
print(f"welcome email to {email}")
Routes call send_welcome.delay(...), and a celery worker runs next to the app. Flask's application context is not present in the worker by default, so tasks that use extensions need the app factory pattern from the Flask docs.
Flask ships with Blinker for in-process signals:
from blinker import signal
user_signed_up = signal("user-signed-up")
@user_signed_up.connect
def send_welcome(sender, **data) -> None:
print(f"welcome email to {data['email']}")
# In the route: user_signed_up.send(None, user_id="u1", email="ada@example.com")
Receivers run synchronously in the request, so a slow email provider slows the signup, and an exception fails it. Nothing is stored, so a crash loses the event.
Python support in Encore is in beta, and you can sign up for access to try it with your Flask app.
Declare a topic, publish from the route after the work is saved, and handle the event in a subscriber that is safe to run twice. With Encore, declare signups = pubsub.Topic[UserSignedUp]("user-signed-up"), publish with await signups.publish(UserSignedUp(...)) in an async view, and register pubsub.Subscription[UserSignedUp](signups, "send-welcome", handler=send_welcome, max_retries=5). Serve Flask under Uvicorn and call await encore.start() at startup.
No. Flask signals use Blinker to call receivers synchronously in the same process. Nothing is persisted or retried, so they help decouple code inside one request but do not move work out of it.
A task queue sends one job to one function. Pub/Sub publishes an event once and delivers it to every subscription, so a welcome email and an analytics sync can both react to one signup without the route knowing about either.
Brokers like SQS and Google Cloud Pub/Sub deliver at least once, so a message can arrive again after a timeout or retry. Record handled message keys under a unique constraint and skip the work when the key already exists.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.