DEV Community

LeopoldHolm3736
LeopoldHolm3736

Posted on

Marketplace Notifications: One Key for Realtime Queues and Accurate Presence

TL;DR: One key across realtime delivery and queues gives every stage a stable name for the same marketplace notification. That key does not make presence accurate, guarantee exactly-once delivery, or replace acknowledgements. It lets the system reconcile a presence decision, retry safely, and suppress the duplicate that otherwise appears when a buyer disconnects at the worst moment.

Choice Presence requirement Failure behavior Best fit
One key, live first, queue on uncertainty Fresh enough to choose a route Reconcile by key after reconnect Most in-app marketplace notifications
Queue first, then project live Presence is advisory Durable work exists before delivery Events that must survive process loss
Live only Presence can be wrong without harm Drop or regenerate Ephemeral hints such as typing state

My default is the first row for notifications such as “seller accepted your offer.” It keeps the common online path quick while preserving one identity when presence turns out to be stale. For a one-person SaaS, that boundary matters more than shaving another abstraction from the diagram: one debugging vocabulary buys back hours that should go into the weekly release.

What does one key actually buy?

Imagine notification offer:814:accepted:v3. The browser sees that key in a live message. The fallback job carries it too. The inbox record, acknowledgement, retry log, and dead-letter record use the same value. Those artifacts are different delivery attempts, but they refer to one user-visible fact.

The key buys correlation and idempotency scope. Correlation means an operator can follow one notification through the live and deferred paths. Idempotency scope means a consumer can ask, “Have I already applied this notification?” before changing UI state. Neither property depends on a particular transport. Consider the messy sequence: a buyer opens an offer in one tab, their phone reconnects, and the seller accepts during that handoff. The old browser connection can still produce a late acknowledgement while the phone receives the queued projection. Without a shared identity, those observations look like separate successes. With it, they remain separate attempts against one event, and the inbox reducer can accept either order without creating two rows.

No magic.

A key cannot prove that a tab is still connected. It cannot prove that a message reached the screen merely because a server wrote bytes. It also cannot turn two independent systems into an atomic transaction. Treating it as an exactly-once switch is the expensive mistake: duplicates and gaps are still possible, so the design must say where durable truth lives and what an acknowledgement means.

For beginners, the clean mental model is an envelope. The envelope has a stable notificationKey, a recipient, an event kind, and a payload. Transport-specific metadata stays outside the identity. A queue receipt may change on every attempt; a socket connection identifier certainly will. Neither belongs in the notification key.

Presence accuracy decides the route

Presence is a time-sensitive observation, not a durable statement about a person. A marketplace user can close a laptop, lose mobile coverage, open a second tab, or reconnect while the server is choosing a path. Even a correct presence record becomes old between lookup and send.

Define presence precisely before choosing architecture. “Connected” might mean that a transport session exists. “Deliverable” is stronger: the server can send and receive a meaningful acknowledgement within the product's allowed window. “Seen” is stronger again and usually belongs to application state, not transport state. If those terms share one Boolean, the system will report confidence it does not have.

I would track at least the observation time and a connection generation. The generation changes when the client establishes a new session. An acknowledgement from an older generation can then be recognized as late rather than accepted as proof about the current session. No invented certainty. Just explicit state.

This is also where the revenue-per-hour lens helps. Perfect global presence across tabs, regions, and devices is a research project. A bounded presence policy is a product decision. Pick how stale an observation may be, what acknowledgement closes a delivery attempt, and when uncertainty must create durable work. Ship that rule, measure it, and revisit it only when customer impact earns the time.

The trade-off is real: stricter freshness checks send more work through the durable path, while looser checks make live attempts faster but increase uncertain outcomes.

Two criteria matter more than transport branding

The first criterion is the cost of a wrong-online decision. Suppose the service reads “online,” starts a live send, and the connection disappears. If the notification represents an accepted offer, losing it is unacceptable. The fallback must become eligible when acknowledgement does not arrive, and the same key lets the eventual consumer recognize a late live delivery as the same event.

The second criterion is the cost of a wrong-offline decision. The user may be online, but stale presence sends the notification through the queue. That usually adds latency and load rather than losing the event. If the queued consumer can also publish to the current session, the outcome is recoverable. The asymmetry is useful: design the uncertain case to fail toward durable handling when the event matters.

Keep the state transition narrow. A practical record can move through pending, live_attempted, acknowledged, and queued. Do not let every transport invent its own business status. The notification key identifies the event; an attempt identifier distinguishes retries; the connection generation tells you which presence observation informed the attempt.

The hard race is straightforward: the live acknowledgement and the fallback enqueue happen at nearly the same time. Resolve both against durable notification state with a conditional update. Whichever transition wins, later work reads the state and becomes a no-op when appropriate. The queue must still tolerate redelivery, because a worker can finish its side effect and fail before recording completion. This shared-key approach is not suitable as a substitute for a durable transaction, an authorization check, or an audit record. It identifies related attempts; it does not prove that any attempt was allowed or committed.

A small TypeScript boundary

The useful abstraction is not a giant realtime client. It is a small application-owned contract that passes the same envelope to either path. This example deliberately leaves storage, transport, and queue implementations behind interfaces; those are undifferentiated pieces I would rather replace than leak throughout marketplace code.

type NotificationEnvelope = {
  notificationKey: string;
  recipientId: string;
  kind: "offer.accepted" | "order.updated";
  payload: Readonly<Record<string, unknown>>;
  createdAt: string;
};

type Presence = {
  connectionGeneration: string;
  observedAt: string;
  deliverable: boolean;
};

interface NotificationStore {
  reserve(envelope: NotificationEnvelope): Promise<"new" | "existing">;
  markAcknowledged(
    notificationKey: string,
    connectionGeneration: string,
  ): Promise<boolean>;
  markQueued(notificationKey: string): Promise<"queued" | "already_closed">;
}

interface LiveChannel {
  send(
    envelope: NotificationEnvelope,
    connectionGeneration: string,
  ): Promise<"acknowledged" | "uncertain">;
}

interface DeferredQueue {
  enqueue(envelope: NotificationEnvelope): Promise<void>;
}

async function deliverNotification(
  envelope: NotificationEnvelope,
  presence: Presence | null,
  store: NotificationStore,
  live: LiveChannel,
  queue: DeferredQueue,
): Promise<void> {
  const reservation = await store.reserve(envelope);
  if (reservation === "existing") return;

  if (presence?.deliverable) {
    const result = await live.send(envelope, presence.connectionGeneration);
    if (result === "acknowledged") {
      const accepted = await store.markAcknowledged(
        envelope.notificationKey,
        presence.connectionGeneration,
      );
      if (accepted) return;
    }
  }

  const transition = await store.markQueued(envelope.notificationKey);
  if (transition === "queued") await queue.enqueue(envelope);
}
Enter fullscreen mode Exit fullscreen mode

There is a deliberate gap between marking work queued and calling enqueue. In a production design, close that gap with an outbox or an equivalent atomic handoff tied to the durable state change. The example exposes the boundary rather than pretending two calls form a transaction. The shared key then follows the outbox record into the worker.

On the client, store applied keys alongside the inbox state, not only in memory. A reconnect can replay a live message, and a second tab may receive the queued projection. Applying offer:814:accepted:v3 twice should leave the same result. If the action itself is not naturally idempotent, the server must guard the state transition.

WebRTC illustrates why identity should sit above transport. Its specification defines peer connections and data channels, including connection state and data transport behavior. Those mechanisms can carry an envelope, but the marketplace still owns notification identity, presence semantics, acknowledgement rules, and durable recovery. A transport connection is evidence used by the decision; it is not the business event.

How should this be tested and operated?

Happy-path tests prove little here. Test the boundaries where the key earns its keep: disconnect after the presence read, acknowledge after fallback becomes eligible, redeliver a queued job, reconnect with a new generation, and deliver to two tabs in opposite orders. Assert the final notification state and visible inbox state, not the number of internal method calls. Use deterministic fault injection around each await point. You want repeatable schedules, including the awkward one where the live send succeeds but its acknowledgement arrives after the durable path starts. A test should show that both attempts share one notification key, have distinct attempt metadata, and converge on one applied user event. Operationally, log the key, recipient, attempt, connection generation, chosen path, presence age, and final transition. Avoid logging the full payload by default; marketplace notifications may contain user-controlled or sensitive data. Metrics should separate routing quality from delivery outcome. A rising queued-after-live rate suggests stale presence or delayed acknowledgements, while duplicate suppression shows retries are occurring and the guard is doing useful work. Notification keys belong in traces and targeted logs, not as unrestricted metric labels. Aggregate metrics by event kind and outcome, then use the key to inspect a specific case. This keeps observability useful without turning a per-event identifier into an operational bill.

Watch cardinality.

Deployment needs one compatibility rule: producers and consumers must agree on key construction before either side changes it. Version the event meaning inside the key only when the user-visible fact changes, as in the v3 example, and document that policy in application code. Random keys are fine when created once and persisted; recomputing a different random key for every retry defeats the entire design.

When is the runner-up better?

Choose queue-first delivery when creating durable work must happen before any attempt to notify. Payment-related state changes, dispute deadlines, and other events whose absence cannot be reconstructed belong there. Commit the business change and its outbox record together, then let a worker project the notification to an online session. Presence still improves latency, but it no longer decides whether durable work exists.

Choose live-only delivery for disposable state. Typing hints, pointer positions, and short-lived presence pulses become misleading when replayed later. Giving them durable queue semantics adds storage, retries, and cleanup without improving the product. Their identity can be scoped to a connection or short session because dropping them is part of the contract. This is the clearest limitation of the shared durable key: retaining and checking identities costs storage, lookup work, and lifecycle management, so it is a poor fit when replay itself would be wrong.

The decision rule is compact: if a user-visible event must survive an incorrect presence decision, give it one durable notification key across the live attempt and deferred path. If durability must precede every delivery, start with the queue. If stale replay is worse than loss, stay live-only.

One key is modest infrastructure, which is exactly why it is useful. It creates a stable join point for delivery, testing, and support without claiming to solve distributed delivery by itself. That is enough to make presence mistakes recoverable while keeping the application small enough to ship weekly.

Sources

Top comments (0)