How to Build a Notification Service in Go

Web
Build a Go notification service using a buffered channel and a worker goroutine to handle messages asynchronously.

The notification queue problem

Your application handles user sign-ups, purchase confirmations, and system alerts. Each action triggers an email or SMS. If you send those messages inside the HTTP handler, the request hangs for seconds while external APIs respond. Users see timeouts. Your server threads pile up. The solution is decoupling: accept the request immediately, hand the work to a background process, and let the handler return.

Go does not require a heavy message broker for this pattern. The standard library provides channels and goroutines, which are lightweight, typed, and built into the language runtime. You can build a reliable notification pipeline with a few lines of code, then scale it when traffic grows.

Channels as the conveyor belt

A channel is a typed conduit that connects goroutines. Think of it as a factory conveyor belt with a fixed number of slots. Producers place items on the belt. Consumers pick them up. The belt enforces order and synchronization. If the belt is empty, consumers block until something arrives. If the belt is full, producers block until a consumer clears a slot.

Buffering changes the blocking condition. An unbuffered channel has zero slots. A send and a receive must happen at the exact same moment for data to transfer. A buffered channel holds a fixed number of items. Producers can push up to that limit without waiting. Once the buffer fills, the producer blocks again. The buffer size is a tuning knob, not a magic fix. It trades memory for latency.

Channels are safe for concurrent use by design. The Go runtime handles locking internally. You never call a lock method on a channel. You send, receive, or close. That is the entire API.

The minimal producer-consumer loop

Here is the simplest working pipeline: spawn a producer, open a buffered channel, consume until the channel closes.

package main

import (
	"fmt"
	"time"
)

// Notification holds a single alert payload.
type Notification struct {
	ID  int
	Msg string
}

func main() {
	// buffered to 10 so the producer can batch sends without blocking
	notifications := make(chan Notification, 10)

	// run producer in background to avoid blocking main
	go func() {
		for i := 1; i <= 5; i++ {
			// push onto the belt; blocks only if buffer is full
			notifications <- Notification{ID: i, Msg: fmt.Sprintf("Alert %d", i)}
			time.Sleep(1 * time.Second)
		}
		// signal consumers that no more data is coming
		close(notifications)
	}()

	// range automatically reads until the channel is closed
	for n := range notifications {
		fmt.Printf("Sent: %s (ID: %d)\n", n.Msg, n.ID)
	}
}

The producer runs in a separate goroutine. It pushes five notifications, pausing one second between each. The main goroutine reads from the channel using a range loop. The loop exits cleanly when the producer calls close. No deadlocks. No manual polling.

Channels are cheap. Goroutines are cheaper.

What happens under the hood

When the program starts, the Go runtime allocates a heap-backed structure for the channel. That structure contains a ring buffer, a read index, a write index, and two wait queues for blocked goroutines. The buffer size you pass to make determines how many Notification structs fit before blocking occurs.

The go func() call tells the scheduler to create a new goroutine. Goroutines start with a 2KB stack that grows and shrinks automatically. The runtime multiplexes thousands of goroutines across a small number of OS threads. This M:N scheduling means you can spawn a goroutine per request without exhausting system resources.

The range loop compiles to a tight receive operation. Each iteration calls the channel's receive method. If the buffer has data, it copies the struct out and advances the read index. If the buffer is empty but the channel is open, the goroutine parks itself on the channel's receive wait queue. The scheduler switches to another runnable goroutine. When the producer pushes data, the runtime wakes the parked consumer and resumes execution.

Calling close sets a flag on the channel. Any blocked receivers wake up and drain remaining buffer items. Once the buffer empties, subsequent receives return the zero value of the element type and a boolean false. The range loop detects that false and exits. If you forget to close the channel, the consumer parks forever. The goroutine leaks. The program hangs or exits with unfinished work.

Trust the scheduler. Park when you wait. Wake when you receive.

A realistic notification worker

Production code needs cancellation, error handling, and a clean shutdown path. Here is how the pattern evolves.

package main

import (
	"context"
	"fmt"
	"time"
)

// Notification holds a single alert payload.
type Notification struct {
	ID  int
	Msg string
}

// Send delivers a notification to an external service.
func (n Notification) Send(ctx context.Context) error {
	// check cancellation before expensive work
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}
	// simulate external API call
	time.Sleep(500 * time.Millisecond)
	fmt.Printf("Delivered: %s (ID: %d)\n", n.Msg, n.ID)
	return nil
}

The Send method follows Go convention: the receiver name is a short lowercase letter matching the type, and context.Context is the first parameter. The select block checks for cancellation before doing work. This prevents wasted CPU when the parent context expires.

func runWorker(ctx context.Context, jobs <-chan Notification) {
	// read until channel closes or context cancels
	for {
		select {
		case <-ctx.Done():
			// exit immediately on shutdown signal
			return
		case n, ok := <-jobs:
			if !ok {
				// channel closed, drain finished
				return
			}
			// handle error explicitly; verbose by design
			if err := n.Send(ctx); err != nil {
				fmt.Printf("Failed %d: %v\n", n.ID, err)
			}
		}
	}
}

The worker uses a select statement to race between context cancellation and channel receives. The ok idiom checks whether the channel closed. The if err != nil block is verbose on purpose. The Go community accepts the boilerplate because it makes failure paths visible. You never swallow errors in production code.

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	jobs := make(chan Notification, 20)

	// spawn three workers to process in parallel
	for i := 0; i < 3; i++ {
		go runWorker(ctx, jobs)
	}

	// produce notifications in background
	go func() {
		for i := 1; i <= 10; i++ {
			jobs <- Notification{ID: i, Msg: fmt.Sprintf("Alert %d", i)}
		}
		close(jobs)
	}()

	// block main until context expires or workers finish
	<-ctx.Done()
}

The main function sets a timeout context, spawns three workers, and starts a producer. The defer cancel() ensures resources release even if the program panics. The final <-ctx.Done() blocks until the timeout fires. In a real service, you would use a sync.WaitGroup to wait for workers to finish instead of a hard timeout.

Context is plumbing. Run it through every long-lived call site.

Where things break

Concurrency bugs in Go usually fall into three categories: panics from misuse, silent leaks from missing cancellation, and race conditions from shared state.

Sending on a closed channel panics immediately. The runtime halts the goroutine with panic: send on closed channel. Only the producer should close a channel. Consumers never close. If multiple goroutines write to the same channel, coordinate who closes it, or use a separate control channel.

Receiving from a closed channel does not panic. It returns the zero value and false. If you ignore the ok variable, you process phantom zero-value notifications. Always check ok when reading from a channel that might close, or use range which handles it automatically.

Goroutine leaks happen when a goroutine waits on a channel that never receives or never closes. The scheduler keeps the goroutine alive. Memory grows. The program eventually exhausts resources. The worst goroutine bug is the one that never logs. Always attach a context with a deadline or timeout to long-running workers. If the parent cancels, the worker exits.

The compiler catches some mistakes early. Forgetting to use a returned error triggers declared and not used. Passing the wrong type to a channel yields cannot use x (type string) as type Notification in send. The type system prevents silent data corruption. You cannot accidentally send a string into a notification channel.

Picking the right concurrency shape

Concurrency is a tool, not a default. Choose the pattern that matches your workload.

Use a buffered channel with a single consumer when you need ordered processing and want to decouple request handling from slow I/O. Use a worker pool with multiple goroutines when you have CPU-bound tasks or want to parallelize independent API calls. Use a sync.WaitGroup with direct function calls when you need to fan out a fixed number of tasks and wait for all of them to finish. Use an external queue like Redis Streams or Kafka when you need persistence across process restarts or multiple services must consume the same events. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Do not reach for goroutines to fix slow code. Profile first. Add concurrency only when the bottleneck is I/O or parallelizable work.

Where to go next