How does testing/synctest let a test of a one-hour backoff wait finish in milliseconds?
answer
- the clock is not the machine's clock
- time moves only when nothing else can
- it jumps to the next deadline
- durably blocked is the key phrase
- real sockets break the illusion
basics
~20 sIt runs the test in a bubble with its own fake clock. That clock moves only when every goroutine in the bubble is durably blocked, then jumps to the next timer deadline, so no real waiting happens.
solid answer
~50 s`synctest.Test` runs a function in a bubble: that goroutine, every goroutine it starts, and every timer they create belong to the bubble and see a fake clock instead of the real one. The clock does not tick with wall time. It advances only when every goroutine in the bubble is durably blocked — blocked in a way only another bubbled goroutine could end, such as a channel operation on a bubble channel or `time.Sleep` — and then it jumps straight to the earliest pending deadline. So an hour of backoff costs microseconds of real time, with no clock interface injected into the code under test. The catch is that blocking on anything outside the bubble, such as a real socket read, is not durable blocking, so tests must use in-memory transports. `synctest.Wait` blocks until all other bubbled goroutines are durably blocked, which is how you assert that nothing further will happen.
code
go · 10 linesfunc TestBackoffWaitsAnHour(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := make(chan struct{})
go func() {
time.Sleep(time.Hour) // returns as soon as the bubble is durably blocked
close(done)
}()
<-done
})
}go deeper
Know that Go has a standard way to test timing without real sleeps, and that it works by giving the test a fake clock rather than by speeding anything up. Being able to name testing/synctest is enough here.
Explain the advance rule precisely: the bubble's clock moves only when every goroutine inside it is durably blocked, and then it jumps to the earliest pending deadline instead of ticking.
Show you can design a test suite around it. Say what has to be in-memory, why a real socket stalls the clock, and how synctest.Wait replaces sleep-and-hope assertions in a retry-and-backoff suite.
Weigh the architectural consequence: adopting it lets you delete a clock-injection layer from exported APIs, but it constrains how code under test does I/O, and that constraint should be a deliberate team standard rather than an accident.
## The problem A retry-and-backoff layer is defined by its timing: 100 ms, then 200, then 400, capped, with jitter, abandoning after a deadline. Testing that honestly has historically meant one of two bad options. Either the test **really sleeps**, which makes it slow and — worse — flaky, because it now depends on the machine's scheduling; or the production code grows a `Clock` interface with `Now`, `Sleep` and `NewTimer` methods threaded through every call site purely so tests can substitute a fake. The second option pollutes an API that other teams read, and the fake clock's semantics never quite match the real one. ## What a bubble is `testing/synctest` offers a third option. `synctest.Test(t, f)` runs `f` inside a **bubble**. The bubble contains the goroutine running `f`, every goroutine started from inside it (transitively), and the channels, timers and `sync` primitives created inside it. Within the bubble, the `time` package reads a **fake clock private to that bubble**. Code outside sees the real clock, unchanged. The rule that makes this work is: > The bubble's clock advances only when **every** goroutine in the bubble is durably blocked, and then it jumps directly to the earliest pending deadline in the bubble. **Durably blocked** means blocked in a state that only another goroutine in the same bubble could end: a send or receive on a channel created inside the bubble, a select over only such cases, `time.Sleep`, waiting on a `sync.WaitGroup` created inside. Being blocked on something *outside* the bubble — a read from a real network connection, a syscall, a channel handed in from outside — is not durable, because the runtime cannot know whether the outside world will ever unblock it. So the bubble runs your code at full speed until nothing can make progress without time passing, then advances time exactly to the next deadline and lets it run again. A backoff schedule adding up to an hour executes in the microseconds its non-sleeping work actually takes. There is no clock interface, no injection, no test-only branch: production code calls `time.Sleep` and `time.NewTimer` exactly as it does in production, and those go onto the runtime's ordinary timer machinery, with a bubble-scoped clock deciding when the deadlines are considered reached. ## synctest.Wait The other primitive is `synctest.Wait`, which blocks the calling goroutine until every *other* goroutine in its bubble is durably blocked. This is the assertion tool. Instead of `time.Sleep(50 * time.Millisecond)` — "probably long enough for the worker to have processed that" — you call `synctest.Wait` and then assert, knowing that nothing further can happen until time moves or you act. ## What breaks a bubble - **Real I/O.** A goroutine blocked on a real socket is not durably blocked, so the clock never advances and the test hangs until its timeout. Use in-memory plumbing: `net.Pipe`, `httptest` servers, fakes that communicate over bubble channels. - **Goroutines started outside.** Only goroutines started inside the bubble belong to it; a background worker started by a package `init` or a shared test fixture is not bubbled and its timers run on the real clock. - **True deadlock.** If every goroutine in the bubble is durably blocked and there is no timer left to fire, nothing can ever make progress, and the bubble reports that rather than hanging silently. ## Versions `testing/synctest` appeared behind a `GOEXPERIMENT` in Go 1.24 and became generally available in Go 1.25 with `synctest.Test` and `synctest.Wait`. Go 1.27 added `synctest.Sleep`, and an in-memory `httptest` test server intended for use inside bubbles. If your module must build on older toolchains, keep the tests behind a build constraint rather than pinning the whole repository. ## Where the judgment lies Adopting `synctest` is a decision about test architecture, not a trick. It rewards code whose timing lives in ordinary `time` calls and whose I/O is behind an interface you can substitute in-memory; it punishes code that reaches for the network or the filesystem in the middle of a retry loop. In practice, teams that adopt it delete a clock-injection layer they had been carrying, which is a public-API simplification as well as a test one. ## What to say in an interview "The test body runs in a bubble with its own fake clock. When every goroutine in the bubble is durably blocked, the clock jumps to the next timer deadline rather than waiting, so a one-hour backoff test is instant and deterministic. `synctest.Wait` lets me assert once everything has settled. The constraint is no real I/O inside the bubble."
- What does synctest.Wait block until?Until every other goroutine in the calling goroutine's bubble is durably blocked. It is the replacement for sleeping a little and hoping the work happened: once it returns, nothing further can occur until time advances or the test acts, so assertions made straight after it are deterministic.
- Why does a real HTTP call inside a bubble break the test?A goroutine blocked on a real socket is not durably blocked, because the runtime cannot know whether the outside world will unblock it. The bubble therefore refuses to advance its clock, and a test that expects a timer to fire simply hangs. Use in-memory transports such as net.Pipe or an httptest server instead.
- Does using synctest remove the need for an injectable clock in production code?Usually yes, and that is much of the appeal: the code under test keeps calling time.Sleep, time.Now and time.NewTimer directly, so no Clock interface leaks into an exported API. Injection still earns its place where behaviour must be tested outside a bubble, or where timing crosses a process boundary the bubble cannot see.
A film set where the clock on the wall is only moved between takes: an hour of story time passes while the crew stands still for a second.
saying these in an interview costs you the question
- Thinks synctest makes real wall-clock time pass faster
- Performs real network calls inside a bubble
- Assumes the fake clock advances while a goroutine spins on CPU
- Believes synctest.Wait waits for a fixed interval
- Starts the goroutines under test outside the bubble and expects them bubbled