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.gocontrol the feature:app.message_sliding_window,app.message_sliding_window_duration, andapp.message_sliding_window_rate. - State Management: The manager tracks window state via
slidingCountandslidingStartfields initialized ininternal/manager/manager.go. - Enforcement: Logic in
internal/manager/pipe.gochecks limits insideNextSubscribers, blocking the pipeline withtime.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
RateCountermechanism.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →