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().

Context and cancellation

The ctx you pass to Send() is not the context the worker runs on. In async mode the job uses context.WithoutCancel(ctx): it keeps every value stored in ctx (trace IDs, request-scoped loggers) but ignores the deadline and cancellation.

This matters in HTTP handlers. The request context is cancelled the moment the handler returns, which is usually long before the delivery and its retries finish. Before v1.0.1 that cancellation aborted the in-flight send; since v1.0.1 it does not.

main.go
func (h *Handler) ship(w http.ResponseWriter, r *http.Request) {
    // r.Context() is cancelled when this handler returns.
    // The async delivery keeps running regardless.
    h.notifier.Send(r.Context(), user, OrderShipped{...})
    w.WriteHeader(http.StatusAccepted)
}

When cancellation must apply — an OTP that is useless once the request has timed out — force that call synchronous with notification.Sync(). In sync mode Send runs on the caller's ctx, and a cancelled context stops the delivery and any remaining retries:

main.go
err := h.notifier.Send(r.Context(), user, LoginOTP{Code: code}, notification.Sync())

A detached async job cannot hang forever: every driver bounds its own requests with the Timeout you configure (HTTP client timeout, or the whole SMTP session for the SMTP driver).

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.

If Close() races with a Send() that already passed the closed check, the job can no longer be enqueued. Since v1.0.1 the notifier logs a warning and fires OnError with ErrClosed for each affected channel instead of dropping the notification silently — so no delivery disappears without a trace. Send itself still returns nil in that case, because it has already handed off to the pool.

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, and also for a job rejected because the notifier was closed concurrently (errors.Is(err, notification.ErrClosed)). It does not fire when RouteNotificationFor returns "" — that channel is skipped silently for the recipient. There's a matching OnSuccess(ctx, n, channel) for delivery confirmations.

See also: Retry & Backoff, Rate Limiting.