How Listmonk Implements Message Rate Limiting with Sliding Windows

Listmonk enforces message rate limiting using a sliding window algorithm that tracks message counts against time intervals, pausing the campaign pipeline when limits are exceeded to protect downstream mail servers.

Listmonk is an open-source newsletter and mailing list manager written in Go. Its campaign processing pipeline supports configurable message rate limiting with sliding windows to prevent overwhelming SMTP providers and avoid IP reputation damage. This mechanism is implemented across the configuration models, the campaign manager state, and the subscriber processing pipeline.

Configuration Settings in models/settings.go

The sliding window behavior is controlled by three fields defined in models/settings.go:

// models/settings.go
AppMessageSlidingWindow         bool   `json:"app.message_sliding_window"`
AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"`
AppMessageSlidingWindowRate     int    `json:"app.message_sliding_window_rate"`

These settings are loaded into the manager's Config struct in internal/manager/manager.go as strongly typed values:

type Config struct {
    // …
    SlidingWindow         bool
    SlidingWindowDuration time.Duration
    SlidingWindowRate     int
}

When the application initializes, these values determine whether the sliding window limiter activates and define the specific time window and message cap.

Manager State Tracking

The campaign manager maintains sliding window state through two fields declared in internal/manager/manager.go:

// internal/manager/manager.go
slidingCount int          // messages sent in the current window
slidingStart time.Time    // start time of the current window

During manager initialization (New), the slidingStart timestamp is set to the current time:

slidingStart: time.Now(),

These fields track the rolling count of messages dispatched within the active window duration, allowing the system to enforce rate limits without external dependencies.

Pipeline Enforcement in pipe.go

The actual throttling logic resides in internal/manager/pipe.go within the NextSubscribers function, which fetches batches of subscribers for active campaigns. The implementation first checks if sliding window mode is enabled:

// internal/manager/pipe.go
hasSliding := p.m.cfg.SlidingWindow &&
               p.m.cfg.SlidingWindowRate > 0 &&
               p.m.cfg.SlidingWindowDuration.Seconds() > 1

When hasSliding evaluates to true, the pipeline examines each message before queuing:

if hasSliding {
    diff := time.Since(p.m.slidingStart)

    // Reset the window when the configured duration has elapsed.
    if diff >= p.m.cfg.SlidingWindowDuration {
        p.m.slidingStart = time.Now()
        p.m.slidingCount = 0
    }

    // Increment the counter for this window.
    p.m.slidingCount++

    // If the count exceeds the configured limit, pause processing.
    if p.m.slidingCount >= p.m.cfg.SlidingWindowRate {
        wait := p.m.cfg.SlidingWindowDuration - diff
        p.m.log.Printf(
            "messages exceeded (%d) for the window (%v since %s). Sleeping for %s.",
            p.m.slidingCount,
            p.m.cfg.SlidingWindowDuration,
            p.m.slidingStart.Format(time.RFC822Z),
            wait.Round(time.Second)*1,
        )
        // Reset the counter and sleep for the remaining window time.
        p.m.slidingCount = 0
        time.Sleep(wait)
    }
}

How the Sliding Window Algorithm Works

Window Reset Logic

When the elapsed time since slidingStart (diff) exceeds the configured SlidingWindowDuration, the manager resets both the start timestamp and message counter. This creates a fresh time window and ensures that rate calculations always reflect the most recent interval rather than accumulating indefinitely.

Limit Breach Handling

If slidingCount reaches SlidingWindowRate before the window expires, the manager calculates the remaining time in the current window (wait) and invokes time.Sleep(wait). This blocks the NextSubscribers loop entirely, preventing additional messages from entering the send queue until the window slides forward. Because this sleep occurs within the message-fetch loop, the campaign effectively pauses rather than dropping messages or failing.

Interaction with Non-Sliding Rate Limiting

Listmonk also supports a simpler per-minute rate limiter implemented via ratecounter.RateCounter, instantiated in pipe.go as ratecounter.NewRateCounter(time.Minute). However, when the sliding window option is enabled (hasSliding == true), the per-minute counter is instantiated but not consulted for throttling decisions. The sliding window implementation takes precedence and provides finer-grained control over burst behavior compared to the rolling minute counter.

Configuration Examples

Enabling Sliding Window via YAML

To activate the limiter in your configuration file:


# config.yml

app:
  message_sliding_window: true
  message_sliding_window_duration: 1m   # 1 minute window

  message_sliding_window_rate: 500      # max 500 messages per window

Programmatic Configuration

When creating a Manager instance directly:

import (
    "time"
    "github.com/knadh/listmonk/internal/manager"
    "github.com/knadh/listmonk/internal/i18n"
    "log"
    "os"
)

func main() {
    cfg := manager.Config{
        BatchSize:             1000,
        Concurrency:           5,
        SlidingWindow:         true,
        SlidingWindowDuration: time.Minute, // 1‑minute window
        SlidingWindowRate:     300,         // 300 msgs per minute
    }

    // Assume `store` implements manager.Store and `i18n` is set up.
    mgr := manager.New(cfg, store, i18n.New(), log.New(os.Stdout, "", log.LstdFlags))
    
    // The manager now enforces sliding window limits automatically.
}

Observing Throttling in Logs

When limits are breached, Listmonk emits log entries similar to:


2026/05/19 10:15:42 messages exceeded (500) for the window (1m0s since Mon, 19 May 2026 10:14:42 +0000). Sleeping for 30s.

This output originates from the log.Printf call in internal/manager/pipe.go and confirms the pipeline is actively throttling to respect the sliding window configuration.

Summary

  • Configuration: Three settings in models/settings.go control the feature: app.message_sliding_window, app.message_sliding_window_duration, and app.message_sliding_window_rate.
  • State Management: The manager tracks window state via slidingCount and slidingStart fields initialized in internal/manager/manager.go.
  • Enforcement: Logic in internal/manager/pipe.go checks limits inside NextSubscribers, blocking the pipeline with time.Sleep() when rates exceed configured thresholds.
  • Burst Prevention: The algorithm resets counters automatically when windows expire and prevents boundary bursts by sleeping through the remainder of exceeded windows.
  • Precedence: When enabled, the sliding window limiter overrides the simpler per-minute RateCounter mechanism.

Frequently Asked Questions

What is the difference between sliding window and fixed window rate limiting?

Sliding window rate limiting tracks message counts against a rolling time interval that moves continuously forward, preventing burst attacks at window boundaries that can occur with fixed windows. Listmonk's implementation checks elapsed time since slidingStart and resets the counter only after the full duration passes, ensuring no fixed reset points exist where bursts could accumulate.

How do I configure message rate limiting with sliding windows in Listmonk?

Set the three configuration parameters in your config.yml file or environment variables: enable app.message_sliding_window (boolean), define app.message_sliding_window_duration as a Go duration string (e.g., "1m", "5m"), and set app.message_sliding_window_rate to the integer maximum messages allowed within that duration. These values map to the Config struct fields consumed by the campaign manager.

What happens when a campaign exceeds the configured sliding window rate?

When slidingCount reaches SlidingWindowRate, the manager calculates the remaining time in the current window and executes time.Sleep(wait) inside the NextSubscribers loop. This pauses campaign processing entirely, logs the throttling event with timestamp details, and resumes only after the window slides forward. Messages are not dropped; the pipeline simply delays further sending.

Does sliding window rate limiting work with concurrent campaign processing?

Yes, but the sliding window state (slidingCount and slidingStart) is maintained per manager instance. If you run multiple manager instances or scale horizontally, each instance tracks its own window independently. For strict global rate limiting across multiple instances, you would need to implement external coordination or reduce the per-instance SlidingWindowRate proportionally.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →