go-notificationgo-notification
Features

Async & Worker Pool

How go-notification dispatches sends concurrently without blocking your request handlers.

Every notifier.Send() call returns immediately. Actual channel dispatch runs on a goroutine worker pool. This is the default — you opt out by setting Async = false.

Why async by default

  • Request handlers don't block on third-party APIs.
  • A slow WhatsApp send doesn't delay your HTTP response.
  • Multiple channels fan out in parallel — four channels with 500ms latency each take ~500ms total, not 2s.

How it works

main.go
notifier := notification.New(notification.Config{
    // Async defaults to true; leave it nil to keep async on.
    WorkerPool: 10,   // default: 10
    QueueSize:  100,  // default: WorkerPool * 10
})

On Send(), each (channel, notification, notifiable) triple is pushed onto an internal queue. A fixed pool of worker goroutines pulls from the queue and invokes the channel's Send().

Configuration

FieldTypeDefaultDescription
Async*booltrueRun dispatch on a worker pool. Set false for synchronous.
WorkerPoolint10Number of dispatcher goroutines (max concurrent in-flight sends).
QueueSizeintWorkerPool * 10Max buffered notifications. A full queue causes Send() to block.

Async is a *bool, so its zero value (nil) is treated as true. To turn async off, set it explicitly with the helper: Async: notification.BoolPtr(false).

Synchronous mode

For CLI tools, cron jobs, or tests where you want blocking behavior and errors returned to the caller, disable async globally:

main.go
notifier := notification.New(notification.Config{
    Async: notification.BoolPtr(false),
})
err := notifier.Send(ctx, user, OrderShipped{}) // blocks until all channels finish

In sync mode the returned error is the joined set of channel failures (errors.Join), each wrapped with its channel name. You can also force a single call to block without changing the global setting by passing notification.Sync():

main.go
err := notifier.Send(ctx, user, OTPCode{Code: "123456"}, notification.Sync())

Closing cleanly

When the program is exiting, call Close() to drain the queue:

main.go
defer notifier.Close()

Close() takes no arguments and returns nothing. It closes the queue and waits for all in-flight sends to finish before returning. It's idempotent, and any Send after Close returns ErrClosed.

What if the queue fills up?

If QueueSize is reached and Send() tries to enqueue more, it blocks until a worker frees a slot — natural backpressure. For truly burst-heavy workloads, increase WorkerPool/QueueSize or spill to a real queue (Redis, NATS, SQS) in front of go-notification.

Error handling

Errors from async sends aren't returned to the Send() caller (it already returned). Handle them via the OnError callback in Config:

main.go
notification.New(notification.Config{
    OnError: func(ctx context.Context, n notification.Notifiable, channel string, err error) {
        slog.Error("send failed",
            "channel", channel,
            "id",      n.GetID(),
            "err",     err,
        )
    },
})

OnError fires after all retries for a channel are exhausted. There's a matching OnSuccess(ctx, n, channel) for delivery confirmations.

See also: Retry & Backoff, Rate Limiting.