Example of a non blocking read and write method for the Golang channel


A channel send or receive blocks until the other side is ready, unless a buffer can satisfy the operation. When a goroutine must remain responsive, use select to try immediately or wait for a bounded time.

The examples below require Go 1.22 or later. Each code block is a complete, standalone program.

When channel operations block

An unbuffered channel has no storage. A send blocks until another goroutine receives the value, and a receive blocks until another goroutine sends one.

A buffered channel lets sends proceed while capacity remains and receives proceed while values remain. A send blocks when the buffer is full; a receive blocks when it is empty.

Receiving from a closed channel never blocks. It returns any buffered values, then the element type’s zero value with ok == false. Sending to a closed channel panics.

Try a send or receive without blocking

A default case makes select return immediately when no communication case is ready. If several cases are ready, Go chooses one pseudo-randomly.

package main

import "fmt"

func main() {
	receiveReady := make(chan int, 1)
	receiveReady <- 42

	select {
	case value := <-receiveReady:
		fmt.Println("received:", value)
	default:
		fmt.Println("no value available")
	}

	receiveEmpty := make(chan int, 1)
	select {
	case value := <-receiveEmpty:
		fmt.Println("received:", value)
	default:
		fmt.Println("no value available")
	}

	sendReady := make(chan int, 1)
	select {
	case sendReady <- 7:
		fmt.Println("sent: 7")
	default:
		fmt.Println("send would block")
	}

	sendFull := make(chan int, 1)
	sendFull <- 1
	select {
	case sendFull <- 2:
		fmt.Println("sent: 2")
	default:
		fmt.Println("send would block")
	}
}

This pattern only attempts the operation once. The default branch does not queue or retry a value, so callers must decide whether to discard it, retry later, or report backpressure.

Wait with time.After

Replace default with a timer channel to wait for either the channel operation or a deadline. time.After is concise and appropriate for occasional timeouts.

package main

import (
	"errors"
	"fmt"
	"time"
)

func readWithAfter(ch <-chan int, timeout time.Duration) (int, error) {
	select {
	case value, ok := <-ch:
		if !ok {
			return 0, errors.New("channel closed")
		}
		return value, nil
	case <-time.After(timeout):
		return 0, errors.New("receive timed out")
	}
}

func writeWithAfter(ch chan<- int, value int, timeout time.Duration) error {
	select {
	case ch <- value:
		return nil
	case <-time.After(timeout):
		return errors.New("send timed out")
	}
}

func main() {
	values := make(chan int)
	go func() {
		time.Sleep(100 * time.Millisecond)
		values <- 42
	}()

	value, err := readWithAfter(values, 500*time.Millisecond)
	fmt.Println("receive:", value, err)

	blocked := make(chan int)
	err = writeWithAfter(blocked, 7, 500*time.Millisecond)
	fmt.Println("send:", err)
}

The duration is exactly 500 milliseconds. Duration constants prevent the microsecond-versus-millisecond unit error that raw numeric values can hide.

Use NewTimer when cleanup matters

time.NewTimer exposes the timer so it can be stopped when the channel operation succeeds first, releasing its resources before the deadline. Stopping is enough here: these timers are function-local and never reused, so no drain is needed. (Avoid the old if !timer.Stop() { <-timer.C } drain idiom — since Go 1.23 the timer channel is unbuffered and that receive can block forever.)

package main

import (
	"errors"
	"fmt"
	"time"
)

func readWithTimer(ch <-chan int, timeout time.Duration) (int, error) {
	timer := time.NewTimer(timeout)

	select {
	case value, ok := <-ch:
		timer.Stop()
		if !ok {
			return 0, errors.New("channel closed")
		}
		return value, nil
	case <-timer.C:
		return 0, errors.New("receive timed out")
	}
}

func writeWithTimer(ch chan<- int, value int, timeout time.Duration) error {
	timer := time.NewTimer(timeout)

	select {
	case ch <- value:
		timer.Stop()
		return nil
	case <-timer.C:
		return errors.New("send timed out")
	}
}

func main() {
	receiveReady := make(chan int, 1)
	receiveReady <- 42
	value, err := readWithTimer(receiveReady, 500*time.Millisecond)
	fmt.Println("receive:", value, err)

	sendReady := make(chan int, 1)
	err = writeWithTimer(sendReady, 7, 500*time.Millisecond)
	fmt.Println("send:", err)
}

Both operations succeed before 500 milliseconds, so their timers are stopped instead of remaining active until the deadline.

Buffered channels and context deadlines

A buffer absorbs short bursts but does not guarantee non-blocking behavior. Once full or empty, the same blocking rules apply. Choose capacity from expected load and define how producers handle a full buffer.

For request-scoped work, a context is usually better than a standalone timer. It propagates cancellation as well as a deadline, and callers can share the same cancellation signal across multiple operations.

package main

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

func readWithContext(ctx context.Context, ch <-chan int) (int, error) {
	select {
	case value, ok := <-ch:
		if !ok {
			return 0, fmt.Errorf("channel closed")
		}
		return value, nil
	case <-ctx.Done():
		return 0, ctx.Err()
	}
}

func writeWithContext(ctx context.Context, ch chan<- int, value int) error {
	select {
	case ch <- value:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

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

	values := make(chan int, 1)
	if err := writeWithContext(ctx, values, 42); err != nil {
		fmt.Println("send:", err)
		return
	}

	value, err := readWithContext(ctx, values)
	fmt.Println("receive:", value, err)
}

Always call the function returned by context.WithTimeout, usually with defer cancel(), to release its resources promptly.