Reconciler Events, workflows, and jobs
Events lost without the Redis event bus
An order comes in, the payment captures, and by every log you can find the checkout worked. But the customer never got a confirmation email. Inventory never synced. Fulfillment never heard about it. Nothing crashed and nothing logged an error, because in Medusa v2 the default Event Module lives entirely in one process's memory, and a process that never saw the event has no way to complain that it missed it. Here is why that happens and a script that finds the orders it happened to.
Medusa v2's default Event Module is @medusajs/event-bus-local, backed by Node's EventEmitter and scoped to a single process's memory. In any multi-process or multi-instance deployment, rolling deploys, autoscaled workers, or a separate API and worker container, an event emitted by one process is never seen by a subscriber in another, and anything emitted during a deploy or restart window is dropped outright because nothing persists it. The fix in production is @medusajs/event-bus-redis plus @medusajs/workflow-engine-redis, but even with Redis configured, subscriber registration races and missing BullMQ retention settings can still lose events. You cannot safely replay a raw event after the fact, so the practical move is to reconcile: list recent orders from the Admin API, cross-reference each against the Notification module's delivery log at /admin/notifications, and flag any order past a grace window with zero notification rows for the expected event_name, such as order.placed. Full code, tests, and a dry run guard are below.
The problem in plain words
Medusa fires events for almost everything that happens to an order: it was placed, a fulfillment was created, a shipment went out. Subscribers listen for those events and do the side effects nobody wants to write inline into the checkout flow, sending the confirmation email, syncing inventory to a third party, notifying a warehouse.
That whole chain depends on the event actually reaching the subscriber. The default Event Module, event-bus-local, is just Node's built-in EventEmitter wrapped in a Medusa interface. An EventEmitter only knows about listeners registered in its own process. It keeps nothing on disk and nothing in a shared store. The moment you run more than one process, whether that is a rolling deploy replacing an old instance with a new one, an autoscaled pool of workers, or a separate API container and worker container, an event emitted in process A is invisible to a subscriber that only exists in process B. Worse, if a restart happens between the emit and the handler running, the event is gone. Nothing failed loudly. Nothing retried. It simply never happened as far as the rest of the system can tell.
Why it happens
Every one of these is a real gap in how the default setup is wired, not a single bug with one cause:
@medusajs/event-bus-localis built on Node'sEventEmitter, which lives entirely in the memory of the process that created it. It was never meant to survive a restart or be shared across instances.- Rolling deploys, autoscaled worker pools, and separate API and worker containers are all multi-process by design. An event emitted in one instance is simply invisible to a subscriber registered in a different instance.
- Any event emitted during the window a process is shutting down or starting up is dropped outright, since nothing persisted it anywhere durable. This is documented in the community as events not triggering consistently even after Redis is introduced, in GitHub issue #4089.
- Even with
@medusajs/event-bus-redisconfigured, the BullMQ processor can start consuming queued events before every subscriber has finished loading and registering, so the very first events after a deploy can run with no handler attached, per GitHub issue #10822. - Notification-specific subscribers have been reported not firing at all against the Redis event bus in some setups, per GitHub issue #7850, and missing
removeOnCompleteorremoveOnFailsettings on the BullMQ queue let completed jobs pile up instead of being purged, masking whether anything is actually being processed.
This is a common source of confusion because nothing in the logs says "event dropped." The order looks completely normal in the Admin, the payment captured fine, and the only sign anything went wrong is a customer support ticket asking where their confirmation email went. See the citations at the end for the exact issues and docs.
You cannot safely re-emit a raw event after the fact. Medusa has no durable log of "this event was supposed to fire and did not," so replaying order.placed blindly risks re-running every subscriber attached to it, not just the one whose side effect actually failed. The safe pattern is reconciliation: compare what should have happened against what the Notification module actually recorded, since /admin/notifications persists every delivery attempt regardless of which Event Module emitted the trigger. Then repair narrowly, by re-invoking the one specific action that produces the missing side effect, guarded by an idempotency check so nothing sends twice.
The fix, as a flow
We do not touch checkout and we do not replay raw events. We list recent orders, list the notifications Medusa actually recorded for each one, and flag any order past a grace window with no notification row for the expected event_name. Behind DRY_RUN, the repair step re-triggers only the specific confirmation action for each flagged order, skipping any order that already has a matching notification to avoid a duplicate email.
Build it step by step
Get an admin session and the base URL
Point the script at your Medusa backend and an admin user with rights to read orders and notifications. Exchange the email and password for a JWT once, then send it as a Bearer token on every admin call. Keep everything in environment variables, never hardcoded.
pip install requests
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" # start safe, only logs the flagged order_id values
npm install @medusajs/js-sdk
export MEDUSA_BACKEND_URL="http://localhost:9000"
export MEDUSA_ADMIN_EMAIL="admin@example.com"
export MEDUSA_ADMIN_PASSWORD="supersecret"
export DRY_RUN="true" // start safe, only logs the flagged order_id values
Authenticate against the Admin API
Both languages exchange credentials for a token the same way. The Python version talks to the REST route directly with requests. The Node version uses the official @medusajs/js-sdk, which wraps the same login call.
import os, requests
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ["MEDUSA_ADMIN_EMAIL"]
PASSWORD = os.environ["MEDUSA_ADMIN_PASSWORD"]
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
import Medusa from "@medusajs/js-sdk";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL;
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD;
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
async function login() {
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
List recent orders, then the notifications recorded for each
Ask for orders sorted newest first with the fields the decision needs: id, display_id, status, fulfillment_status, payment_status, created_at, and the customer. Page through with limit and offset. Then, for every order, list the notifications Medusa actually recorded against it. /admin/notifications is a real v2 admin resource backed by the Notification Module, and it persists every notification attempt regardless of which Event Module triggered it, so it tells the truth even when the event bus itself lost the signal.
ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer"
def list_recent_orders(token, limit=100):
headers = {"Authorization": f"Bearer {token}"}
out, offset = [], 0
while True:
r = requests.get(
f"{BASE_URL}/admin/orders",
params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset, "order": "-created_at"},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["orders"])
offset += limit
if offset >= body["count"]:
return out
def list_notifications_for_order(token, order_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/notifications",
params={"resource_id": order_id, "resource_type": "order", "limit": 50},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["notifications"]
const ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer";
async function listRecentOrders(sdk, limit = 100) {
const out = [];
let offset = 0;
while (true) {
const body = await sdk.admin.order.list({
fields: ORDER_FIELDS, limit, offset, order: "-created_at",
});
out.push(...body.orders);
offset += limit;
if (offset >= body.count) return out;
}
}
async function listNotificationsForOrder(sdk, orderId) {
const body = await sdk.client.fetch("/admin/notifications", {
method: "GET",
query: { resource_id: orderId, resource_type: "order", limit: 50 },
});
return body.notifications;
}
Decide, with one pure function
Keep the decision in a function with no network calls. Build a set of order ids that already have a notification with the expected event_name, then flag any order older than a grace window, for example ten minutes, to allow for async delivery, that is not in that set. The function takes both arrays plus the time bounds and returns the minimal tuples for a flag, nothing more.
from datetime import datetime, timezone
def find_orders_missing_notification(orders, notifications, expected_event, grace_ms, now_ms):
"""Pure: no I/O. orders and notifications are plain dicts/lists already fetched."""
notified_ids = {
n["resource_id"]
for n in notifications
if n.get("resource_type") == "order" and n.get("event_name") == expected_event
}
flagged = []
for order in orders:
created_ms = datetime.fromisoformat(
order["created_at"].replace("Z", "+00:00")
).timestamp() * 1000
if (now_ms - created_ms) > grace_ms and order["id"] not in notified_ids:
flagged.append({"order_id": order["id"], "expected_event": expected_event})
return flagged
export function findOrdersMissingNotification(orders, notifications, expectedEvent, graceMs, nowMs) {
// Pure: no I/O. orders and notifications are plain arrays already fetched.
const notifiedIds = new Set(
notifications
.filter((n) => n.resource_type === "order" && n.event_name === expectedEvent)
.map((n) => n.resource_id)
);
const flagged = [];
for (const order of orders) {
const createdMs = Date.parse(order.created_at);
if (nowMs - createdMs > graceMs && !notifiedIds.has(order.id)) {
flagged.push({ order_id: order.id, expected_event: expectedEvent });
}
}
return flagged;
}
Repair by re-triggering the specific action, guarded and idempotent
Do not fabricate historical notifications and do not replay the raw event. For each flagged order, skip it if a notification for that event_name and resource_id already exists by the time the repair runs, someone else may have fixed it, and only then call the narrow admin action that re-sends the confirmation. This example re-triggers a custom subscriber endpoint your app exposes for this purpose, which is the safest shape since it isolates the one side effect instead of re-emitting an event with unknown subscribers attached.
def already_notified(token, order_id, expected_event):
notifications = list_notifications_for_order(token, order_id)
return any(n.get("event_name") == expected_event for n in notifications)
def retrigger_order_confirmation(token, order_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/orders/{order_id}/resend-confirmation",
json={},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
async function alreadyNotified(sdk, orderId, expectedEvent) {
const notifications = await listNotificationsForOrder(sdk, orderId);
return notifications.some((n) => n.event_name === expectedEvent);
}
async function retriggerOrderConfirmation(sdk, orderId) {
return sdk.client.fetch(`/admin/orders/${orderId}/resend-confirmation`, {
method: "POST",
body: {},
});
}
Wire it together with a dry run guard
The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only reports the flagged orders, one record per order, with the fields an ops queue needs: order id, display id, expected event, missing since, and customer email. Read the output, get a human to approve the batch, then switch DRY_RUN off to let it re-trigger for real, still guarded by the idempotency check on every single order.
Never auto-fabricate a historical notification and never replay a raw event blindly. Always start with DRY_RUN=true, always require a human to approve the batch before writing, and always re-check for an existing notification immediately before re-triggering, so a slow run or a second operator can never send the same confirmation twice.
The full code
Here is the complete script in one file for each language. It authenticates, lists recent orders and their notifications, flags every order missing the expected event with a pure function, and either reports the batch or re-triggers the specific confirmation action depending on DRY_RUN, skipping any order idempotency already covers.
View this code on GitHub Full runnable folder with tests in the medusa-fixes repo.
"""Find Medusa v2 orders whose expected notification, such as order.placed,
never fired because the default in-memory event-bus-local dropped the event
across processes or a restart. Never replays raw events and never fabricates
historical notifications. DRY_RUN=true only reports the flagged orders. Safe
to run again and again, because repair is guarded by an idempotency check
against the Notification module's own log.
"""
import os
import logging
from datetime import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("find_missing_notifications")
BASE_URL = os.environ.get("MEDUSA_BACKEND_URL", "http://localhost:9000")
EMAIL = os.environ.get("MEDUSA_ADMIN_EMAIL", "admin@example.com")
PASSWORD = os.environ.get("MEDUSA_ADMIN_PASSWORD", "supersecret")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
EXPECTED_EVENT = os.environ.get("EXPECTED_EVENT", "order.placed")
GRACE_MINUTES = float(os.environ.get("GRACE_MINUTES", "10"))
ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer"
def get_token():
r = requests.post(
f"{BASE_URL}/auth/user/emailpass",
json={"email": EMAIL, "password": PASSWORD},
timeout=30,
)
r.raise_for_status()
return r.json()["token"]
def list_recent_orders(token, limit=100):
headers = {"Authorization": f"Bearer {token}"}
out, offset = [], 0
while True:
r = requests.get(
f"{BASE_URL}/admin/orders",
params={"fields": ORDER_FIELDS, "limit": limit, "offset": offset, "order": "-created_at"},
headers=headers,
timeout=30,
)
r.raise_for_status()
body = r.json()
out.extend(body["orders"])
offset += limit
if offset >= body["count"]:
return out
def list_notifications_for_order(token, order_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{BASE_URL}/admin/notifications",
params={"resource_id": order_id, "resource_type": "order", "limit": 50},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()["notifications"]
def find_orders_missing_notification(orders, notifications, expected_event, grace_ms, now_ms):
"""Pure: no I/O. orders and notifications are plain dicts/lists already fetched."""
notified_ids = {
n["resource_id"]
for n in notifications
if n.get("resource_type") == "order" and n.get("event_name") == expected_event
}
flagged = []
for order in orders:
created_ms = datetime.fromisoformat(
order["created_at"].replace("Z", "+00:00")
).timestamp() * 1000
if (now_ms - created_ms) > grace_ms and order["id"] not in notified_ids:
flagged.append({"order_id": order["id"], "expected_event": expected_event})
return flagged
def already_notified(token, order_id, expected_event):
notifications = list_notifications_for_order(token, order_id)
return any(n.get("event_name") == expected_event for n in notifications)
def retrigger_order_confirmation(token, order_id):
headers = {"Authorization": f"Bearer {token}"}
r = requests.post(
f"{BASE_URL}/admin/orders/{order_id}/resend-confirmation",
json={},
headers=headers,
timeout=30,
)
r.raise_for_status()
return r.json()
def run():
token = get_token()
orders = list_recent_orders(token)
all_notifications = []
for order in orders:
all_notifications.extend(list_notifications_for_order(token, order["id"]))
now_ms = datetime.now().timestamp() * 1000
grace_ms = GRACE_MINUTES * 60 * 1000
flagged = find_orders_missing_notification(orders, all_notifications, EXPECTED_EVENT, grace_ms, now_ms)
if not flagged:
log.info("No orders missing %s across %d order(s).", EXPECTED_EVENT, len(orders))
return
by_id = {o["id"]: o for o in orders}
for item in flagged:
order = by_id[item["order_id"]]
customer = order.get("customer") or {}
log.warning(
"Order %s (display_id=%s) missing %s since %s. customer_email=%s",
order["id"], order.get("display_id"), item["expected_event"],
order["created_at"], customer.get("email"),
)
if not DRY_RUN:
for item in flagged:
order_id = item["order_id"]
if already_notified(token, order_id, item["expected_event"]):
log.info("Order %s already notified since the scan ran. Skipping.", order_id)
continue
log.info("Order %s: re-triggering %s.", order_id, item["expected_event"])
retrigger_order_confirmation(token, order_id)
log.info("Done. %d order(s) %s.", len(flagged), "to review" if DRY_RUN else "processed")
if __name__ == "__main__":
run()
/**
* Find Medusa v2 orders whose expected notification, such as order.placed,
* never fired because the default in-memory event-bus-local dropped the
* event across processes or a restart. Never replays raw events and never
* fabricates historical notifications. DRY_RUN=true only reports the
* flagged orders. Safe to run again and again, because repair is guarded
* by an idempotency check against the Notification module's own log.
*/
import { pathToFileURL } from "node:url";
const BASE_URL = process.env.MEDUSA_BACKEND_URL || "http://localhost:9000";
const EMAIL = process.env.MEDUSA_ADMIN_EMAIL || "admin@example.com";
const PASSWORD = process.env.MEDUSA_ADMIN_PASSWORD || "supersecret";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const EXPECTED_EVENT = process.env.EXPECTED_EVENT || "order.placed";
const GRACE_MINUTES = Number(process.env.GRACE_MINUTES || 10);
const ORDER_FIELDS = "id,display_id,status,fulfillment_status,payment_status,created_at,*customer";
export function findOrdersMissingNotification(orders, notifications, expectedEvent, graceMs, nowMs) {
// Pure: no I/O. orders and notifications are plain arrays already fetched.
const notifiedIds = new Set(
notifications
.filter((n) => n.resource_type === "order" && n.event_name === expectedEvent)
.map((n) => n.resource_id)
);
const flagged = [];
for (const order of orders) {
const createdMs = Date.parse(order.created_at);
if (nowMs - createdMs > graceMs && !notifiedIds.has(order.id)) {
flagged.push({ order_id: order.id, expected_event: expectedEvent });
}
}
return flagged;
}
async function login() {
const { default: Medusa } = await import("@medusajs/js-sdk");
const sdk = new Medusa({ baseUrl: BASE_URL, auth: { type: "jwt" } });
await sdk.auth.login("user", "emailpass", { email: EMAIL, password: PASSWORD });
return sdk;
}
async function listRecentOrders(sdk, limit = 100) {
const out = [];
let offset = 0;
while (true) {
const body = await sdk.admin.order.list({
fields: ORDER_FIELDS, limit, offset, order: "-created_at",
});
out.push(...body.orders);
offset += limit;
if (offset >= body.count) return out;
}
}
async function listNotificationsForOrder(sdk, orderId) {
const body = await sdk.client.fetch("/admin/notifications", {
method: "GET",
query: { resource_id: orderId, resource_type: "order", limit: 50 },
});
return body.notifications;
}
async function alreadyNotified(sdk, orderId, expectedEvent) {
const notifications = await listNotificationsForOrder(sdk, orderId);
return notifications.some((n) => n.event_name === expectedEvent);
}
async function retriggerOrderConfirmation(sdk, orderId) {
return sdk.client.fetch(`/admin/orders/${orderId}/resend-confirmation`, {
method: "POST",
body: {},
});
}
export async function run() {
const sdk = await login();
const orders = await listRecentOrders(sdk);
const allNotifications = [];
for (const order of orders) {
allNotifications.push(...(await listNotificationsForOrder(sdk, order.id)));
}
const nowMs = Date.now();
const graceMs = GRACE_MINUTES * 60 * 1000;
const flagged = findOrdersMissingNotification(orders, allNotifications, EXPECTED_EVENT, graceMs, nowMs);
if (flagged.length === 0) {
console.log(`No orders missing ${EXPECTED_EVENT} across ${orders.length} order(s).`);
return;
}
const byId = new Map(orders.map((o) => [o.id, o]));
for (const item of flagged) {
const order = byId.get(item.order_id);
console.warn(
`Order ${order.id} (display_id=${order.display_id}) missing ${item.expected_event} since ${order.created_at}. customer_email=${order.customer?.email}`
);
}
if (!DRY_RUN) {
for (const item of flagged) {
if (await alreadyNotified(sdk, item.order_id, item.expected_event)) {
console.log(`Order ${item.order_id} already notified since the scan ran. Skipping.`);
continue;
}
console.log(`Order ${item.order_id}: re-triggering ${item.expected_event}.`);
await retriggerOrderConfirmation(sdk, item.order_id);
}
}
console.log(`Done. ${flagged.length} order(s) ${DRY_RUN ? "to review" : "processed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The function worth testing is the one that decides the outcome, find_orders_missing_notification. It is pure, no network and no database, so the tests feed in plain order and notification arrays plus a fixed clock and check the answer.
from find_missing_notifications import find_orders_missing_notification
NOW_MS = 1_752_105_600_000 # 2026-07-10T00:00:00Z in epoch ms
GRACE_MS = 10 * 60 * 1000 # 10 minutes
def order(**over):
base = {"id": "order_1", "created_at": "2026-07-09T23:00:00Z", "fulfillment_status": "not_fulfilled"}
base.update(over)
return base
def notification(**over):
base = {"resource_id": "order_1", "resource_type": "order", "event_name": "order.placed"}
base.update(over)
return base
def test_flags_order_past_grace_with_no_notification():
result = find_orders_missing_notification([order()], [], "order.placed", GRACE_MS, NOW_MS)
assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]
def test_does_not_flag_when_notification_exists():
result = find_orders_missing_notification([order()], [notification()], "order.placed", GRACE_MS, NOW_MS)
assert result == []
def test_does_not_flag_within_grace_window():
recent = order(created_at="2026-07-09T23:58:00Z")
result = find_orders_missing_notification([recent], [], "order.placed", GRACE_MS, NOW_MS)
assert result == []
def test_ignores_notification_for_a_different_event():
result = find_orders_missing_notification(
[order()], [notification(event_name="order.fulfillment_created")], "order.placed", GRACE_MS, NOW_MS
)
assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]
def test_ignores_notification_for_a_different_resource_type():
result = find_orders_missing_notification(
[order()], [notification(resource_type="customer")], "order.placed", GRACE_MS, NOW_MS
)
assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]
def test_handles_multiple_orders_independently():
orders = [order(), order(id="order_2", created_at="2026-07-09T22:00:00Z")]
notifications = [notification(resource_id="order_2")]
result = find_orders_missing_notification(orders, notifications, "order.placed", GRACE_MS, NOW_MS)
assert result == [{"order_id": "order_1", "expected_event": "order.placed"}]
import { test } from "node:test";
import assert from "node:assert/strict";
import { findOrdersMissingNotification } from "./find-missing-notifications.js";
const NOW_MS = Date.parse("2026-07-10T00:00:00Z");
const GRACE_MS = 10 * 60 * 1000;
const order = (over = {}) => ({
id: "order_1",
created_at: "2026-07-09T23:00:00Z",
fulfillment_status: "not_fulfilled",
...over,
});
const notification = (over = {}) => ({
resource_id: "order_1",
resource_type: "order",
event_name: "order.placed",
...over,
});
test("flags order past grace with no notification", () => {
const result = findOrdersMissingNotification([order()], [], "order.placed", GRACE_MS, NOW_MS);
assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});
test("does not flag when notification exists", () => {
const result = findOrdersMissingNotification([order()], [notification()], "order.placed", GRACE_MS, NOW_MS);
assert.deepEqual(result, []);
});
test("does not flag within grace window", () => {
const recent = order({ created_at: "2026-07-09T23:58:00Z" });
const result = findOrdersMissingNotification([recent], [], "order.placed", GRACE_MS, NOW_MS);
assert.deepEqual(result, []);
});
test("ignores notification for a different event", () => {
const result = findOrdersMissingNotification(
[order()], [notification({ event_name: "order.fulfillment_created" })], "order.placed", GRACE_MS, NOW_MS
);
assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});
test("ignores notification for a different resource type", () => {
const result = findOrdersMissingNotification(
[order()], [notification({ resource_type: "customer" })], "order.placed", GRACE_MS, NOW_MS
);
assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});
test("handles multiple orders independently", () => {
const orders = [order(), order({ id: "order_2", created_at: "2026-07-09T22:00:00Z" })];
const notifications = [notification({ resource_id: "order_2" })];
const result = findOrdersMissingNotification(orders, notifications, "order.placed", GRACE_MS, NOW_MS);
assert.deepEqual(result, [{ order_id: "order_1", expected_event: "order.placed" }]);
});
Case studies
The confirmation emails that vanished during a release
A store deployed a small config change during a moderately busy afternoon. The deploy replaced the API container while the old one was still draining a handful of in-flight checkouts. Every order placed in that narrow window emitted order.placed into a process that was already shutting down, and the new process never had a subscriber listening in time to catch it.
Nobody noticed until three customers asked support where their receipt was. Running the reconciler against orders from that afternoon found nine orders with zero order.placed notification rows, all clustered in the same two minute deploy window. The team approved a batch re-trigger, the idempotency check confirmed none of the nine had a notification recorded since, and all nine got their confirmation email for the first time.
The worker that started consuming before the app finished loading
A team had already moved to @medusajs/event-bus-redis, expecting the durability problem to be solved. During a scaling event, a new worker instance came up, its BullMQ processor started pulling queued jobs almost immediately, but the application's own subscriber registration, which happens during Medusa's bootstrap, had not finished attaching handlers yet.
A batch of fulfillment notification events processed with no handler attached and were marked complete by BullMQ regardless, exactly the shape described in GitHub issue #10822. The reconciler caught it the same way, by comparing order.fulfillment_created notifications against orders whose fulfillment_status had moved to fulfilled. The durable fix was adding a startup delay before the processor began consuming, plus removeOnComplete and removeOnFail settings so future gaps would be visible instead of silently purged.
Run this reconciler on a schedule, or right after a deploy window. It never replays a raw event and never fabricates a notification record, so it can never send a customer two receipts or mask a real gap. It only tells you which orders never got the side effect Medusa itself was supposed to trigger, cross-referenced against the Notification module's own durable log, and it only writes when a human has approved the batch and the idempotency check confirms nothing changed underneath it. The durable fix stays infrastructure-level: configure @medusajs/event-bus-redis and @medusajs/workflow-engine-redis in every deployed environment, and set BullMQ's removeOnComplete and removeOnFail so completed events are purged instead of piling up.
FAQ
Why do order confirmation emails sometimes never send in Medusa?
Medusa v2's default Event Module, event-bus-local, is backed by Node's EventEmitter and lives entirely inside one process's memory. In a multi-process deployment, an event emitted by one process is never seen by a subscriber running in another, and any event emitted during a deploy or restart window is dropped outright because nothing persists it. If the subscriber that sends the confirmation email never receives order.placed, the email never sends and nothing in the database records that it was supposed to.
Does switching to the Redis event bus fully fix lost events?
It fixes the core problem, since @medusajs/event-bus-redis persists events in Redis through BullMQ instead of process memory. But it is not automatically perfect. Subscriber registration races, where the BullMQ processor starts consuming before all subscribers finish loading, and missing removeOnComplete or removeOnFail settings, which let queues grow unbounded, can still cause events to run before a handler attaches or to pile up silently.
How do I find orders that already missed a notification, without replaying every event?
Cross-reference the Admin API's orders list against the Notification module's delivery log at /admin/notifications, which records every notification attempt regardless of which Event Module emitted it. An order older than a short grace window with zero notification rows for the expected event_name, such as order.placed, is the signature of a dropped event. This needs no event replay, only two read-only Admin API calls.
Related field notes
Citations
On the problem:
- medusajs/medusa GitHub issue #4089: Events not triggered consistently using Redis EventBusModule. github.com/medusajs/medusa/issues/4089
- medusajs/medusa GitHub issue #10822: event-bus-redis processor executes event before subscribers are loaded. github.com/medusajs/medusa/issues/10822
- medusajs/medusa GitHub issue #7850: Events in Redis event bus not triggering subscribers (notification service). github.com/medusajs/medusa/issues/7850
On the solution:
- Medusa Documentation: Redis Event Module. docs.medusajs.com/resources/infrastructure-modules/event/redis
- Medusa Documentation: Workflow Engine Module. docs.medusajs.com/resources/infrastructure-modules/workflow-engine
- Medusa Documentation: Events and Subscribers. docs.medusajs.com/learn/fundamentals/events-and-subscribers
Stuck on a tricky one?
If you have a problem in Medusa pricing, inventory, orders, promotions, or workflows that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a silently dropped event?
If this saved you from a customer support ticket or a confusing missing email, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi