JACOB LURIE
Achievement Unlocked (Eventually)

Achievement Unlocked (Eventually)

Getting a Python Celery worker to talk to a mobile app in realtime using Redis pub/sub and WebSockets


When done correctly, badges can add quick depth and gamification to a user experience. Who doesn’t like a shiny gold medal for doing something you were going to do anyway?

There’s an easy way to do badges and a hard way. Naturally, I picked the hard way first, realized it was the easy way, and then found the actually hard way. Here’s how I chose to architect them to work in realtime — and the lessons I learned along the way.


Badges

When building Nomad this problem presented itself, with one fun caveat — the calculation to determine if a badge is awarded was done on a worker, not directly in an API call. I wanted badges to show up the instant they’re earned from a user’s actions. So the question became: how do you get a background process that has no idea the client exists to pass the good news directly to a user’s phone?

The Easy Way

Before going into the real solution, let me walk through the naive approach — the one that works perfectly until it doesn’t. First, let’s define what we’re working with:

  1. Badges have metadata stored in a Badge table
  2. Each badge has a set of one or more rules defined in a BadgeRequirements table
  3. Badge progress/completion is tracked in a UserBadge table

Badges Table Example:
idkeynamedescriptioncreated_at
1places_visited_55 Places VisitedVisit 5 different places2026-01-01 00:00:00
2images_posted_1010 Images PostedPost 10 images2026-01-10 00:00:00

BadgeRequirements Table Example:
idbadge_idmetricoperatorthresholdscopecreated_at
11visits>=5all_time2026-01-01 00:00:00
22images_count>=10all_time2026-01-10 00:00:00

UserBadges Table Example:
iduser_idbadge_idcurrent_valuecompleted_atlast_evaluated_atresource_idresource_type
110013null2026-02-01 10:00:00nullnull
21012122026-02-07 18:00:002026-02-07 18:00:00nullnull

For this to make sense, every time a user modifies a document attached to a badge we have to check against their existing data to evaluate:

What progress was achieved on that badge? Did this user achieve a new badge?

Let’s say a user creates a visit. We need to check if they qualify for the places_visited_5 badge, achieved when a user logs 5 visits in the app. Easy, right? Just run a query, see if they hit the milestone, update the progress, and move on. Barely any performance impact. Famous last words.

But now suppose there are 25 different badge types, each with their own set of rules related to visits and other data. Suddenly our “quick check” is 25 quick checks, each with their own queries, all crammed into a single request. The “easy” way is to evaluate all of this every single time, update progress for all 25 badges, attach the results to the response, and ship it back. What could go wrong?

User creates a new visit
(POST /visits)
Visit saved to database
BadgeRequirements related to visits are evaluated for all 25 badges
UserBadges table is updated for all 25 badges
achieved_badges attached to response
Client displays new badges on successful response

As the number of badges grows, evaluating all badge requirements can quickly become resource-intensive. More importantly, performing these checks during each request can delay the client’s response after a visit is created — which impedes the primary goal of the endpoint. Your user just wants to save a visit, not wait around while the server plays judge on 25 different achievement criteria. Nobody signed up for that loading spinner.


The Solution

Enter two more pieces to the puzzle — Redis and a worker server. Now we have the tools to accomplish our two goals:

  1. Communicate the successful creation of a visit to the client as soon as possible
  2. Instantly begin evaluating badges on the creation of a visit

Since badge evaluation can be computationally expensive, we can offload that work to an asynchronous task. In my case, since the API was built with Flask, this would be a Celery task. Fire off the task, send the response, and let the worker sort out the badges.

The new flow looks like this:

Client: User creates visit
(POST /visits)
Server: Visit saved to DB
Server: Celery task enqueued
Server: Response sent immediately
Client: Receives success response
Meanwhile, asynchronously:
Worker: Task picked up
Worker: Evaluates badge rules
Worker: UserBadges updated
How do we notify the client about badge updates?

The Last Mile

The worker knows you earned a badge. But your phone doesn’t. The worker has no concept of HTTP responses or connected clients — it just crunches numbers and writes to the database. So how do we close the loop?

The key insight is that Celery already uses Redis as a message broker. Redis can do more than queue tasks and cache data — it gives us pub/sub for free via an events stream.

Here’s how it works:

Worker: completes badge evaluation
Worker: Publishes event to Redis pub/sub
API: Listens to Redis pub/sub events
API: Forwards event through WebSocket
Client: Receives badge notification in realtime

Once the visit is persisted and the transaction is committed, the API enqueues the badge evaluation task with Celery and returns the response to the client without waiting. The worker picks up the task asynchronously:

# API (python): After visit is created and saved
db.session.commit()

# Enqueue evaluation task for visits
visit_badge_task.delay(
    user_id=user_id, 
    resource_id=visit.id, 
    resource_type="visit"
)

When the worker finishes evaluating badge rules, if it awarded any new badges it publishes an event to the Redis pub/sub channel with the user id and the list of badges so whoever is listening can route the notification:

# Worker (python): After badge evaluation completes
if awarded_badges:
    publish_redis_event(
        "badge_earned",
        {
            "user_id": user_id,
            "badges": awarded_badges,
            "task_id": self.request.id,
        },
    )

Each API instance runs a background thread subscribed to that Redis channel. When a message arrives, it parses the payload and emits to the socket room for that user — so the instance that has that user’s WebSocket is the one that actually delivers the event:

# API (python): Background thread listening to Redis events
for message in pubsub.listen():
    if message.get("type") != "message":
        continue
    
    data = json.loads(message["data"])
    event = data.get("event")
    payload = data.get("payload", {})
    user_id = payload.get("user_id")
    
    if user_id:
        socketio.emit(event, payload, room=f"user_{user_id}")

On the client, a socket listener is already attached to the WebSocket connection. When the badge_earned event arrives, the app pushes the new badges into a queue and surfaces them in the UI (e.g. a dialog or toast):

// Client (React Native): Listening for badge events (useSocketEvents.ts)
socket.on("badge_earned", (data) => {
  // Add badges to the queue for showing dialogs
  addBadges(data.badges);
});

By using a WebSocket connection between the client and the API, the API can forward Redis events to the relevant connected user in realtime, instantly notifying the client to display the new badge. The worker never needs to know the client exists — it just shouts into Redis, the API catches it, and the WebSocket delivers the good news. Achievement unlocked, no page refresh required.

One nice property of this setup: it plays well with horizontal scaling. A user’s WebSocket is only ever connected to one API node — they’re in exactly one socket room on one server. Every API instance, though, subscribes to the same Redis pub/sub stream. So when the worker publishes a badge event, every server receives it. Each server runs socketio.emit(..., room=f"user_{user_id}"); only the instance that actually has that user in its room will have anyone to deliver to. The others are just emitting into an empty room. So no matter which server the user is connected to, that’s the one that propagates the event to their client. No sticky sessions or shared socket state required.

Now we’ve solve both problems:

  1. The original create-visit request stays fast — no badge math blocking the response
  2. Users see their badges the moment they’re earned, not on the next app refresh

Want to try Nomad? Download it for free on the App Store.