Building a Comment System That Doesn’t Break Under Spam: Pre-Moderate vs Post-Moderate

Every dev who’s ever shipped a public comment section has, at some point, stared into the abyss of a spam-filled thread and wondered if it was all worth it. Bot accounts posting affiliate links, trolls nuking discussions, and the occasional person who types their entire life story in one unformatted paragraph.

Comment moderation is one of those problems that looks trivial on a whiteboard and turns into a full-time job in production. The core architectural decision — do you hold every comment for review before it goes live, or do you let it through and clean up after? — has massive downstream consequences on your UX, infrastructure, and moderator sanity.

This article walks through both models end-to-end: the data model, the API contract, the queue mechanics, and where each approach falls apart under real traffic.


The Two Models, Honestly Described

Pre-moderation: nothing is visible until a human (or an automated rule) explicitly approves it. The comment exists in a pending state, invisible to other users.

Post-moderation: comments go live immediately. Moderation happens reactively — flagged by users, caught by automated filters, or reviewed in batches.

Neither is universally "correct." Your choice should be driven by your audience, legal obligations, and moderator headcount — not by what feels safer.

A children’s education platform? Pre-moderate everything, no exceptions. A developer community? Post-moderate with aggressive spam filtering. A news comment section in a jurisdiction with liability concerns for third-party content? You might want pre-moderation with an automated first pass to kill the obvious garbage before a human sees it.


Data Model First

Get this wrong and you’ll be migrating schema under live traffic six months from now. Model the full comment lifecycle from day one.

-- comments table: the source of truth
CREATE TABLE comments (
    id          BIGSERIAL PRIMARY KEY,
    post_id     BIGINT NOT NULL,
    parent_id   BIGINT REFERENCES comments(id) ON DELETE CASCADE,
    author_id   BIGINT,                        -- NULL for anonymous
    author_name VARCHAR(120),                  -- denormalized for guest comments
    author_email VARCHAR(255),
    body        TEXT NOT NULL,
    body_html   TEXT,                          -- sanitized HTML, generated on approval
    status      VARCHAR(20) NOT NULL DEFAULT 'pending',
                -- pending | approved | rejected | spam | deleted
    moderation_mode VARCHAR(20) NOT NULL,
                -- pre | post — recorded at submission time
    ip_address  INET,
    user_agent  TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    reviewed_at TIMESTAMPTZ,
    reviewed_by BIGINT                         -- moderator user id
);

CREATE INDEX ON comments(post_id, status, created_at DESC);
CREATE INDEX ON comments(status, created_at ASC); -- moderation queue ordering
CREATE INDEX ON comments(author_id) WHERE author_id IS NOT NULL;
CREATE INDEX ON comments(ip_address, created_at DESC); -- rate-limit lookups

The status column is the heart of everything. Never use a boolean is_approved. You need at minimum four states: pending, approved, rejected, spam. Add deleted if you want soft deletes (you do — you’ll need audit trails).

moderation_mode recorded at submission time is a small detail that pays off. When you switch from post-moderate to pre-moderate mid-launch, you need to know which comments were already live under the old policy vs which ones slipped through.


API Design

Three actors touch your comment API: submitters, readers, and moderators. Design separate surfaces for each.

Submission endpoint

POST /api/v1/comments
Content-Type: application/json

{
  "post_id": 42,
  "parent_id": null,
  "author_name": "Jane Doe",
  "author_email": "[email protected]",
  "body": "Great writeup. Question about the Redis config..."
}

The response differs by moderation mode:

// Pre-moderate: comment is pending
{
  "id": 1337,
  "status": "pending",
  "message": "Your comment is awaiting moderation."
}

// Post-moderate: comment is live
{
  "id": 1338,
  "status": "approved",
  "body_html": "<p>Great writeup...</p>"
}

Don’t return a 202 Accepted for pre-moderation and a 201 Created for post-moderation. That’s a footgun for frontend devs. Always return 201 with the status field telling the real story.

Reader endpoint

GET /api/v1/posts/42/comments?page=1&per_page=50

This only returns approved comments, always. The query is dead simple:

SELECT * FROM comments
WHERE post_id = $1
  AND status = 'approved'
  AND parent_id IS NULL
ORDER BY created_at ASC
LIMIT $2 OFFSET $3;

Fetch replies in a second query keyed by parent_id, or do a recursive CTE if you need nested threading. Don’t mix pending comments into the reader response under any circumstances — that’s a pre-moderation bypass waiting to happen.

Moderation queue endpoint

GET /api/v1/moderation/queue?status=pending&page=1
Authorization: Bearer <moderator-token>
SELECT * FROM comments
WHERE status = 'pending'
ORDER BY created_at ASC  -- FIFO, oldest first
LIMIT 50;

FIFO matters here. LIFO queues leave old comments rotting at the bottom while fresh ones get immediate attention. Use FIFO.


Moderation Queue Mechanics

For small-traffic sites, polling the database every N seconds is completely fine. Stop overthinking it.

For anything above a few hundred submissions per day, put a queue in front.

# docker-compose.yml
services:
  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/comments
      REDIS_URL: redis://redis:6379
    depends_on: [db, redis]

  worker:
    build: ./worker
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/comments
      REDIS_URL: redis://redis:6379
    depends_on: [db, redis]
    command: python worker.py

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: comments
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

When a comment lands, the API writes it to Postgres (source of truth) and pushes its ID onto a Redis list:

# api/submit.py
import redis
import psycopg2

r = redis.Redis.from_url(os.getenv("REDIS_URL"))

def submit_comment(data, moderation_mode):
    with db.cursor() as cur:
        cur.execute(
            """
            INSERT INTO comments
              (post_id, parent_id, author_name, author_email, body, status, moderation_mode, ip_address)
            VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
            RETURNING id
            """,
            (
                data["post_id"], data.get("parent_id"),
                data["author_name"], data["author_email"],
                data["body"],
                "pending" if moderation_mode == "pre" else "approved",
                moderation_mode,
                request.remote_addr
            )
        )
        comment_id = cur.fetchone()[0]
        db.commit()

    if moderation_mode == "pre":
        # Push to human review queue
        r.rpush("queue:moderation", comment_id)
    else:
        # Push to async processing: spam scoring, notification emails, etc.
        r.rpush("queue:post_process", comment_id)

    return comment_id

The worker picks off the queue and does the heavy lifting — spam scoring with something like Akismet or a local ML model, duplicate detection, sending notification emails to post authors:

# worker/worker.py
import time

def process_post_moderate(comment_id):
    comment = db.get_comment(comment_id)
    spam_score = spam_checker.score(comment)

    if spam_score > 0.85:
        db.update_status(comment_id, "spam")
        return

    # Send notification to post author
    notifications.new_comment(comment)

def run():
    while True:
        _, raw = r.blpop(["queue:post_process", "queue:moderation"], timeout=5)
        if raw is None:
            continue
        comment_id = int(raw)
        try:
            process_post_moderate(comment_id)
        except Exception as e:
            # Log, then push to dead letter queue
            r.rpush("queue:dlq", comment_id)
            logger.exception(f"Failed processing comment {comment_id}: {e}")

Always have a dead-letter queue. Comments that fail processing should land somewhere you can inspect and retry, not silently disappear.


Automated First Pass

Running every comment through a spam filter before a human sees it is not optional at any meaningful scale. It cuts your moderation workload by 70–90% on most public forums.

For self-hosted setups, the options are:

  • Akismet — battle-tested, free for personal use, HTTP API. Has seen more spam than any human alive.
  • Perspective API (Google) — toxicity scoring, useful for pre-filtering harassment.
  • Local rules engine — keyword lists, regex, IP reputation. Dumb but fast and private.

Layer them. Run your local rules first (zero latency, zero API cost), then call Akismet for anything that passes. Expensive ML models go last, only for borderline cases.

def automated_verdict(comment):
    # Layer 1: local rules (< 1ms)
    if local_rules.is_spam(comment):
        return "spam", 1.0

    # Layer 2: Akismet (~100ms)
    if akismet.check(comment):
        return "spam", 0.95

    # Layer 3: toxicity scoring for borderline content
    score = perspective.toxicity(comment["body"])
    if score > 0.9:
        return "pending", score  # send to human, don't auto-reject

    return "approved", score

Never auto-reject based on toxicity scores alone. False positives on legitimate nuanced criticism will kill your community faster than spam does.


Gotchas

1. Cache invalidation on approval

If you cache comment threads (you should), approving a comment in the moderation queue must invalidate the cache for that post. Sounds obvious. It gets forgotten constantly. The symptom is moderators approving comments that don’t appear on the site for hours.

def approve_comment(comment_id, moderator_id):
    comment = db.get_comment(comment_id)
    db.update_status(comment_id, "approved", moderator_id)
    # Don't forget this:
    cache.delete(f"comments:post:{comment['post_id']}")

2. Thundering herd on the moderation queue

If you have multiple moderator workers polling the same queue, two workers can claim the same comment. Use SELECT ... FOR UPDATE SKIP LOCKED in Postgres — it’s built exactly for this:

SELECT id, body, author_name, post_id
FROM comments
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;

This is atomic. No two workers ever grab the same row.

3. Reply visibility in pre-moderate mode

A user posts a comment. Gets approved. Replies to their own comment while it’s pending. Now you have a reply to an invisible parent. Decide your policy upfront: either cascade-hold replies until the parent is approved, or show approved replies under a "reply to deleted/pending comment" placeholder. Most systems take the cascade approach. Whatever you pick, encode it explicitly.

4. Email as a moderation lever

Sending the submitter a "your comment is pending" email is fine. Sending them an approval notification is fine. Sending them a rejection email is a moderation nightmare — it tells spammers exactly which filters they tripped. Reject silently. Only notify on approval.

5. Pre-moderation latency kills engagement

This is the real cost of pre-moderation that nobody talks about. A user posts a thoughtful comment and waits 8 hours for a response. They don’t come back. On a high-traffic post, pre-moderation can crater engagement by 60% compared to post-moderation with good spam filtering. Know this going in.


Production-Ready Additions

Rate limiting at the IP level before anything hits your database:

# nginx.conf snippet
limit_req_zone $binary_remote_addr zone=comments:10m rate=2r/m;

location /api/v1/comments {
    limit_req zone=comments burst=3 nodelay;
    proxy_pass http://api:8000;
}

Two comments per minute per IP, burst of three. Adjust to your audience. Anonymous users should have tighter limits than authenticated ones.

Shadow banning is worth implementing. A shadow-banned user’s comments appear approved to them but have a hidden shadow_banned flag visible only to moderators and excluded from public queries. It’s ethically grey but devastatingly effective against persistent bad actors who keep registering new accounts. When a troll thinks their harassment is getting through, they stop escalating.

Moderator audit log — every status change should be recorded. Not just reviewed_by and reviewed_at on the comment row, but a separate moderation_actions table:

CREATE TABLE moderation_actions (
    id          BIGSERIAL PRIMARY KEY,
    comment_id  BIGINT NOT NULL REFERENCES comments(id),
    moderator_id BIGINT NOT NULL,
    action      VARCHAR(20) NOT NULL,  -- approved | rejected | spam | deleted
    reason      TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

When a moderator gets accused of bias, you have receipts.


Which One to Actually Choose

If you’re launching a new community with unknown audience quality: start with pre-moderation, build your reputation list (known good commenters get auto-approved), then relax to post-moderation as you calibrate your automated filters.

If you’re adding comments to an established platform with a known audience: post-moderation with a solid first-pass filter. Pre-moderation will frustrate regulars who expect their comments to appear immediately.

If you have zero moderator capacity: post-moderation with aggressive automated filtering plus user-flagging. You have no other choice. Pre-moderation requires humans in the loop.

The hybrid approach — trusted users skip the queue, new accounts go to pre-moderation — is what most mature platforms land on after a year or two. It’s more complex to implement but the user experience is dramatically better. Keep it in mind as your north star even if you don’t build it on day one.

The code, the schema, the queue mechanics — that’s all table stakes. The real work is defining your policies clearly before you write a single line, and building a data model that can represent the full lifecycle without surgery when the policies change. They always change.

Leave a comment

👁 Views: 9,433 · Unique visitors: 14,506