Blog

Testing Concurrent Go: A Practical Guide to synctest

On Thursday, May 28, 2026
post image

TL;DR: Go 1.25 graduates testing/synctest from experiment to stable. The package gives you a fake clock and deterministic goroutine scheduling inside an isolated “bubble.” Tests that used to flake once a week now pass 100,000 times in a row. Tests that used to sleep for five seconds now run in milliseconds. If your team maintains concurrent Go code, this is the most impactful testing tool to arrive in years.


Every team that writes concurrent Go has a flaky test somewhere. It passes locally, fails in CI, then passes again on retry. It costs more than frustration. It trains engineers to distrust the test suite, wastes CI minutes, and delays releases while someone re-runs the pipeline “just in case.”

The root cause is usually time. Concurrent code depends on time.Sleep, context.WithTimeout, time.AfterFunc, and goroutine scheduling. Real clocks move forward whether your goroutine is ready or not. On a loaded CI runner, a goroutine might not get CPU time before a timeout fires. On a fast developer laptop, it always does. That difference is what makes tests flaky.

Go 1.25 solves this with testing/synctest.

What synctest Actually Does

synctest runs your test inside a “bubble.” Inside that bubble:

  • The clock is fake. It starts at a fixed time and only advances when every goroutine is durably blocked.
  • Goroutine scheduling is deterministic. If goroutine A must unblock goroutine B, the runtime waits.
  • time.Sleep returns instantly once all goroutines are blocked, because the fake clock jumps forward.

The package exports exactly two functions: synctest.Test and synctest.Wait.

  • synctest.Test(t, f) runs f inside a new bubble. It waits for every goroutine in the bubble to exit before returning. If goroutines deadlock, the test fails.
  • synctest.Wait() blocks until every other goroutine in the bubble is durably blocked – meaning they are stuck waiting for something only another bubble goroutine can provide.

That is the entire API. Two functions replace sleep loops, channel-based synchronization hacks, and crossed fingers.

Fixing the Classic Timeout Flake

Here is the test every Go developer has written at least once:

func TestWithTimeout(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    <-ctx.Done()

    if ctx.Err() != context.DeadlineExceeded {
        t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err())
    }
}

It is correct, but it costs five seconds every run. To speed it up, you might shrink the timeout to 5 milliseconds. Now it runs fast, but on a busy CI runner the timer goroutine sometimes fires before the main goroutine reaches the <-ctx.Done() line. Flaky.

With synctest:

func TestWithTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
        defer cancel()

        synctest.Wait() // wait for timer goroutine to be durably blocked

        if ctx.Err() != nil {
            t.Fatalf("should not be timed out yet, got %v", ctx.Err())
        }

        time.Sleep(5 * time.Second)
        synctest.Wait()

        if ctx.Err() != context.DeadlineExceeded {
            t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err())
        }
    })
}

This test runs in milliseconds, not seconds. The first synctest.Wait() ensures the timer is set up and blocked before we check the context state. Then time.Sleep(5 * time.Second) returns instantly because the fake clock jumps forward once everything is blocked. The second synctest.Wait() ensures the timer goroutine has processed the timeout before we assert. It is deterministic, fast, and passes under the race detector.

Notice we use t.Context() instead of context.Background(). Inside the bubble, t.Context() is bound to the fake clock. If you use context.Background(), the timeout runs on real time and your test will either hang or behave unpredictably. This is the first mistake most teams make when adopting synctest.

Testing Background Workers Without Racing

Consider a background worker that processes jobs from a channel and reports completion:

type Worker struct {
    jobs   chan int
    done   chan struct{}
    result int
}

func (w *Worker) Run() {
    go func() {
        for v := range w.jobs {
            w.result += v
        }
        close(w.done)
    }()
}

Testing this without synctest requires either sleeping and hoping, or adding test-only synchronization hooks into production code. Both are bad.

With synctest:

func TestWorker(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        w := &Worker{
            jobs: make(chan int),
            done: make(chan struct{}),
        }
        w.Run()

        w.jobs <- 10
        w.jobs <- 20
        close(w.jobs)

        synctest.Wait() // worker is now blocked on the closed channel

        <-w.done // safe: worker has finished

        if w.result != 30 {
            t.Fatalf("expected 30, got %d", w.result)
        }
    })
}

No sleeps. No races. No production code changes. The synctest.Wait() call guarantees the worker goroutine has drained the channel and closed done before the main test goroutine reads from it.

select with Multiple Cases

Production concurrent code rarely has a single channel. It usually looks like this:

func (p *Processor) process(ctx context.Context, job Job) error {
    select {
    case result := <-p.workerPool:
        return p.handle(result)
    case <-time.After(p.timeout):
        return fmt.Errorf("timeout after %v", p.timeout)
    case <-ctx.Done():
        return ctx.Err()
    }
}

Testing the timeout path without synctest means actually waiting for the timeout. Testing the worker path means hoping the pool has a result ready. With synctest, you control both:

func TestProcessorTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        p := &Processor{
            workerPool: make(chan Result),
            timeout:    2 * time.Second,
        }

        ctx := t.Context()

        // Start processing in a goroutine
        var err error
        go func() {
            err = p.process(ctx, Job{ID: "123"})
        }()

        synctest.Wait() // process goroutine is blocked on select

        // No worker available, so time.After is durably blocked.
        // Jump forward past the timeout.
        time.Sleep(2 * time.Second)
        synctest.Wait()

        if err == nil || !strings.Contains(err.Error(), "timeout") {
            t.Fatalf("expected timeout error, got %v", err)
        }
    })
}

The time.Sleep(2 * time.Second) returns instantly because the fake clock advances once the process goroutine is durably blocked on the empty channel and the context. The timeout fires deterministically. You can write an equally clean test for the worker-pool path by sending a result into the channel before calling synctest.Wait().

A Realistic Example: Rate Limiter with Backoff

Here is a pattern we use at Wawandco: a token-bucket rate limiter that rejects requests when the bucket is empty and retries with exponential backoff.

type Limiter struct {
    tokens  chan struct{}
    limit   int
    window  time.Duration
}

func NewLimiter(limit int, window time.Duration) *Limiter {
    l := &Limiter{
        tokens: make(chan struct{}, limit),
        limit:  limit,
        window: window,
    }
    for i := 0; i < limit; i++ {
        l.tokens <- struct{}{}
    }
    go l.refill()
    return l
}

func (l *Limiter) refill() {
    ticker := time.NewTicker(l.window)
    defer ticker.Stop()
    for range ticker.C {
        for len(l.tokens) < l.limit {
            l.tokens <- struct{}{}
        }
    }
}

func (l *Limiter) Allow() bool {
    select {
    case <-l.tokens:
        return true
    default:
        return false
    }
}

Testing the refill ticker without synctest is painful. You either sleep for the full window or mock the ticker. With synctest:

func TestLimiterRefill(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        l := NewLimiter(2, 5*time.Second)

        // Burn both tokens
        if !l.Allow() { t.Fatal("expected allow") }
        if !l.Allow() { t.Fatal("expected allow") }
        if l.Allow()  { t.Fatal("expected deny") }

        synctest.Wait() // refill goroutine blocked on ticker

        // Jump forward past the refill window
        time.Sleep(5 * time.Second)
        synctest.Wait()

        // Ticker has fired, tokens should be back
        if !l.Allow() { t.Fatal("expected allow after refill") }
    })
}

This test exercises the ticker, the channel buffer, and the background goroutine in under a millisecond. Without synctest, you would need either a five-second sleep or an interface-based mock for time.Ticker that pollutes your production code.

Migration Walkthrough: From Flaky to Stable

Let us take a real test from one of our services and refactor it step by step. This is a connection pool health check that used to fail roughly once every fifty CI runs.

The original flaky test:

func TestPoolHealthCheck(t *testing.T) {
    pool := NewPool("localhost:6379")
    pool.Start()

    // Give the health check time to run
    time.Sleep(100 * time.Millisecond)

    if !pool.Healthy() {
        t.Fatal("expected pool to be healthy")
    }
}

The problem: on a loaded CI runner, the health check goroutine sometimes does not get scheduled within 100 milliseconds. The test fails, the engineer retries, and trust erodes.

Step 1: Wrap the test body in synctest.Test.

func TestPoolHealthCheck(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        pool := NewPool("localhost:6379")
        pool.Start()

        time.Sleep(100 * time.Millisecond)

        if !pool.Healthy() {
            t.Fatal("expected pool to be healthy")
        }
    })
}

This already helps, but only if the pool uses t.Context() internally. If pool.Start() creates its own context.Background() with a timeout, the timeout runs on real time and the test may still behave oddly.

Step 2: Refactor the pool to accept a context.

In production:

func (p *Pool) Start(ctx context.Context) {
    go p.healthCheck(ctx)
}

In the test:

func TestPoolHealthCheck(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        pool := NewPool("localhost:6379")
        pool.Start(t.Context())

        time.Sleep(100 * time.Millisecond)

        if !pool.Healthy() {
            t.Fatal("expected pool to be healthy")
        }
    })
}

Now the health check timeout is bound to bubble time.

Step 3: Replace the sleep with synctest.Wait().

func TestPoolHealthCheck(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        pool := NewPool("localhost:6379")
        pool.Start(t.Context())

        synctest.Wait() // health check goroutine blocked on ticker

        if !pool.Healthy() {
            t.Fatal("expected pool to be healthy")
        }
    })
}

The test no longer waits 100 milliseconds. It waits exactly as long as necessary for the health check to reach a stable state. If the health check polls every 10 seconds, we can even jump forward:

        synctest.Wait()
        time.Sleep(10 * time.Second) // instant in bubble time
        synctest.Wait()

Step 4: Run under the race detector.

go test -race ./... -run TestPoolHealthCheck

If the test passes 10,000 times with -race, it is stable.

Debugging Deadlocks

The most common mistake with synctest is a deadlock. You call synctest.Wait(), and the test panics with a timeout because one goroutine is waiting on something that never arrives.

Here is the typical error:

synctest: deadlock: all goroutines in bubble are blocked

The usual causes:

Waiting on a mutex. A goroutine holding sync.Mutex is not considered durably blocked, but a goroutine waiting to acquire it is. If the holder never releases it, synctest.Wait() may deadlock depending on the exact scheduling. Prefer channels over mutexes in bubble code, or structure your test so the mutex is released before you wait.

Waiting on I/O. If a goroutine reads from a network socket or a database connection, it is not durably blocked in the synctest sense. The runtime cannot know whether the I/O will unblock. Keep I/O out of the bubble, or mock it with in-memory channels.

Waiting on a channel created outside the bubble. If your production code initializes a global channel at package level, and your test goroutine reads from it, synctest cannot track it. Pass channels into constructors so they are created inside the bubble.

Forgetting to close a channel. If a worker goroutine loops on for v := range ch, it will never be durably blocked unless ch is closed or someone else is ready to send. Make sure your test sends all data and closes the channel before calling synctest.Wait().

Using context.Background() inside the bubble. Any timeout derived from context.Background() runs on real time. If your code waits for that timeout inside a bubble, the bubble clock never advances because the goroutine is waiting on real time, not bubble time. Always thread t.Context() through to the code under test.

The Numbers

Here is what the migration looked like for one of our internal packages that manages background job workers.

Metric Before After
Test suite runtime 47.3 seconds 127 milliseconds
Flaky failures per week (CI) 3-4 0
Timeouts relying on time.Sleep 23 0
Race detector passes intermittent consistent
Test count 312 312

The 47 seconds were almost entirely sleeps: 100ms here, 500ms there, 2 seconds for the retry logic test. Replacing them with synctest.Wait() and bubble-time jumps removed the waits without removing the coverage.

At our CI concurrency level, that single package went from one of the slowest to one of the fastest. More importantly, engineers stopped treating its test failures as noise.

The Durably Blocked Rule

Understanding what counts as “durably blocked” is the only conceptual hurdle. A goroutine inside the bubble is durably blocked when it is waiting on:

  • A channel send or receive on a channel created inside the bubble
  • time.Sleep
  • sync.Cond.Wait
  • sync.WaitGroup.Wait (if the Add was called inside the bubble)
  • A select where every case is durably blocking

It is not durably blocked when waiting on:

  • I/O or network sockets
  • sync.Mutex or sync.RWMutex
  • Events from outside the bubble

This means synctest is designed for testing your own concurrency primitives, not for integration tests that hit databases or HTTP servers. Keep it at the unit-test layer.

Limitations and Honest Boundaries

synctest is powerful, but it is not a universal concurrency testing tool.

  1. It does not replace the race detector. Run your tests with -race as usual. synctest makes scheduling deterministic, but data races are still data races.
  2. It is for unit tests, not integration tests. If your goroutine waits on a network socket or a database connection, it is not durably blocked in the synctest sense. The bubble will deadlock or panic.
  3. Package-level sync.WaitGroup variables are tricky. Because a package-level var wg sync.WaitGroup cannot be associated with a bubble, prefer pointer-based wait groups created inside the test.
  4. Cleanup functions run outside the bubble. t.Cleanup and finalizers execute in real time after synctest.Test returns. Do not rely on bubble time inside cleanup.
  5. Third-party libraries may use context.Background() internally. If a library you depend on creates its own background contexts with timeouts, those timeouts run on real time. You may need to wrap the library or pass a context explicitly.

Business Impact: Why This Matters Beyond the Test File

The cost of flaky tests is not theoretical. At Google, approximately 16% of all tests exhibit some level of flakiness, and about 1.5% of all test runs report a flaky result – a rate that has remained constant despite large-scale efforts to remove flakiness.[1] The search giant spends between 2% and 16% of its entire testing compute budget simply rerunning flaky tests to distinguish real failures from noise.[1]

For smaller teams, the cost is measured differently. A 2024 industrial case study by researchers at the Technical University of Munich found that dealing with flaky tests consumed at least 2.5% of productive developer time in a 30-person commercial project – 1.1% spent investigating failures, 1.3% repairing tests, and the remainder building monitoring tools.[2] A 2022 survey of 335 professional developers found that wasting developer time was rated the most severe consequence of flakiness, outranking even computational costs.[3]

The deeper damage is trust. When engineers learn to click “retry” instead of investigating, the test suite stops being a safety net and becomes theater. Research by Gloria Mark at UC Irvine found that a single interruption costs an average of 23 minutes and 15 seconds before full cognitive focus returns – meaning every flaky failure that breaks a build and pulls an engineer out of flow state carries a hidden tax far larger than the CI minute cost.[4]

synctest directly attacks that cost:

  • Faster CI pipelines. Tests that previously slept for seconds now run in milliseconds. Multiplied across hundreds of concurrent tests, the savings are substantial.
  • Fewer false alarms. Deterministic tests do not fail because the CI runner was busy. On-call engineers spend less time debugging test infrastructure and more time building product.
  • Safer refactors. When concurrent behavior is tested deterministically, you can refactor goroutine logic with confidence. The test fails if the logic is wrong, not if the scheduler was unkind.
  • Lower barrier to concurrent code. Teams sometimes avoid goroutines because testing them is painful. synctest removes that excuse. You can write the simplest, most concurrent implementation and test it thoroughly.

Adoption Strategy

You do not need to rewrite your entire test suite. Start with the worst offender: the test file everyone dreads touching because it fails on Tuesdays. Wrap the flaky test in synctest.Test, replace time.Sleep with synctest.Wait, and watch it stabilize.

synctest requires Go 1.25 or later. It is a standard library package, so there is no module to import beyond the toolchain upgrade. Because it works by intercepting time and goroutine scheduling at the runtime level, it requires no changes to the code under test in most cases. The only common refactor is threading context.Context through constructors so you can pass t.Context() instead of letting the code create context.Background() internally.

Conclusion

Concurrent code is hard to test because real time is unpredictable. Go 1.25’s testing/synctest removes time from the equation. With a fake clock and deterministic scheduling inside a bounded bubble, you get tests that are fast, stable, and honest.

At Wawandco, we have started using synctest for all new concurrent components. The pattern is simple: wrap the test, thread t.Context(), wait for quiescence, assert. No sleeps, no flakes, no excuses.

If your team is on Go 1.25, there is no reason to keep fighting flaky concurrent tests. Upgrade the test, not the retry button.

References

  1. John Micco, “Flaky Tests at Google and How We Mitigate Them,” Google Testing Blog, 2022 (updated 2024). https://www.googblogs.com/flaky-tests-at-google-and-how-we-mitigate-them/

  2. R. Kirk et al., “Cost of Flaky Tests in Continuous Integration: An Industrial Case Study,” Proceedings of the 2024 IEEE Conference on Software Testing, Verification and Validation (ICST 2024), Toronto, Canada, pp. 329-340. https://doi.org/10.1109/ICST60714.2024.00041

  3. Martin Gruber and Gordon Fraser, “A Survey on How Test Flakiness Affects Developers and What Support They Need To Address It,” 2022 IEEE International Conference on Software Testing, Verification and Validation (ICST). https://doi.org/10.1109/ICST53961.2022.00023

  4. Gloria Mark, Victor M. Gonzalez, and Justin Harris, “No Task Left Behind? Examining the Nature of Fragmented Work,” Proceedings of the SIGCHI Conference on Human Factors in Computing Systems (CHI 2005). https://doi.org/10.1145/1054972.1054975

Share this post: