DEV Community

KendrickBerg5327
KendrickBerg5327

Posted on

Queue-Based Image Review and Uploader Notifications for Both Decisions in 2026

Short answer: put every uploaded image behind a durable review queue, persist an explicit approved or rejected decision, and notify the uploader from an idempotent outbox worker. In an Express application, the HTTP request should acknowledge the upload; it should not wait for image review or notification delivery.

The page that wakes me up is usually not “the classifier is slow.” It is “an image is visible, but the uploader never got a decision.” That symptom can mean a lost queue message, a worker that applied the decision twice, or a notification sent before the database commit. Treating review as one synchronous Node.js function hides all three failure modes.

What should the image review queue guarantee?

Start with a small state machine: uploaded -> pending -> approved|rejected. Store the image's object key, policy version, reviewer or model evidence, and decision timestamp in the same database that owns the listing. A queue message contains only an immutable review ID, not a mutable URL. The consumer can then fetch the current record and safely retry it.

The queue needs at-least-once delivery, a visibility timeout longer than the normal review call, and a dead-letter path. “Exactly once” is a comforting label, not a useful assumption. Make the transition conditional instead: a worker may move pending to a terminal state once, and later deliveries become no-ops.

type Review struct {
    ID          string
    UploadID    string
    Status      string // pending, approved, rejected
    Policy      string
    DecisionAt  time.Time
}

func applyDecision(r *Review, decision string, now time.Time) bool {
    if r.Status != "pending" {
        return false // duplicate delivery or a late retry
    }
    if decision != "approved" && decision != "rejected" {
        return false
    }
    r.Status = decision
    r.DecisionAt = now
    return true
}
Enter fullscreen mode Exit fullscreen mode

That conditional update is the important part. In SQL it should be an atomic UPDATE ... WHERE status = 'pending', followed by an outbox insert in the same transaction. The outbox row carries the review ID, recipient, decision, and a unique event key such as review:{id}:{decision}.

That is the contract.

How do Node.js and Express handle both review outcomes?

Express should validate the upload, enqueue a review ID, and return 202 Accepted. It can return a status URL, but the browser must not infer approval from that response. A separate worker owns the classifier call and writes one of the two terminal decisions. Both branches need the same audit fields and the same notification path; the message text is the only policy-specific difference.

Here is the worker shape in Go. The storage and queue interfaces are deliberately generic, so the same boundary can sit behind a Node.js service or a Go sidecar.

type ReviewStore interface {
    ClaimPending(context.Context, string) (*Review, error)
    CommitDecision(context.Context, *Review, string) error
    AddOutbox(context.Context, string, string, string) error
}

func handle(ctx context.Context, store ReviewStore, reviewID string, classify func(context.Context, string) (string, error)) error {
    review, err := store.ClaimPending(ctx, reviewID)
    if err != nil {
        return err
    }
    if review == nil {
        return nil // already terminal; acknowledge the duplicate message
    }
    decision, err := classify(ctx, review.UploadID)
    if err != nil {
        return err // leave pending; the queue will retry
    }
    if err := store.CommitDecision(ctx, review, decision); err != nil {
        return err
    }
    eventKey := "review:" + review.ID + ":" + decision
    return store.AddOutbox(ctx, eventKey, review.UploadID, decision)
}
Enter fullscreen mode Exit fullscreen mode

The notification worker claims outbox rows with a lease, sends the approved or rejected event, and marks the row delivered. A timeout must release the lease. If the provider accepts a message and the process dies before marking it delivered, a retry can still occur, so include the event key in the notification payload and deduplicate at the consumer or notification gateway.

Which signals expose a broken moderation workflow?

Measure the whole path, not just model latency: queue age by percentile, pending reviews older than the service-level target, decision rates by policy version, outbox age, delivery retries, and duplicate event keys. Alert on a growing pending count and on “terminal decision without delivered notification.” Those two gauges catch different incidents.

The image itself deserves checks before review. Validate the declared and sniffed media type, enforce a byte limit, strip metadata where appropriate, and generate a safe derivative for reviewers. The MIME label alone is not proof of file content; the browser upload guide from MDN is a useful baseline, not a complete threat model.

I once expected a queue-depth alert to catch a stalled consumer. It did not: the queue was empty because a deploy acknowledged messages before committing the database update. The deploy looked healthy in the dashboard, and the HTTP endpoint kept returning 202, so the first useful clue came from a support ticket rather than telemetry. We traced one upload through object storage, the queue, the worker log, and the review table; the message had been acknowledged in the narrow gap between the database write and the process exit. A transaction-bound outbox would have kept the notification intent next to the decision, while an alert on old pending rows would have caught the missing commit earlier. The fix was small, but the investigation was not: every component reported success according to its own local definition.

Ack it.

What are the trade-offs of this queue and notification design?

The durable design costs a database table, a worker fleet, and some operational vocabulary. A synchronous request is easier to demo and is unsuitable when reviews can exceed request timeouts, when uploads spike, or when the user must receive a durable decision. Keep synchronous processing for a low-volume internal tool with no retry requirement; use the queue for a public marketplace.

Do not silently map uncertainty to approval. A third internal state such as needs_review can remain pending while a human checks it, even if the external contract exposes only approved or rejected. Define who can override a decision, retain the policy version, and make appeals produce a new review ID rather than mutating the old event.

False positives have a cost: rejected legitimate product photos create support work and lost listings. False negatives have a different cost: unsafe media reaches customers. Tune thresholds against a labeled sample, review both outcomes, and record the reason code so a later policy change can be audited. I’m not sure one threshold will fit every catalog; your mileage may vary by region, category, and reviewer capacity.

Further reading

Top comments (0)