{"id":272,"date":"2026-05-24T05:03:10","date_gmt":"2026-05-24T05:03:10","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/golang-concurrency-patterns-2026-what-go-1-25-let-me-delete\/"},"modified":"2026-05-24T05:03:10","modified_gmt":"2026-05-24T05:03:10","slug":"golang-concurrency-patterns-2026-what-go-1-25-let-me-delete","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/golang-concurrency-patterns-2026-what-go-1-25-let-me-delete\/","title":{"rendered":"Go Concurrency Patterns in 2026: What Go 1.25 Let Me Delete"},"content":{"rendered":"<p>I keep a folder of code I&rsquo;ve deleted on purpose. Not the lost-to-a-bad-merge kind. The kind you remove because the language finally grew a better way to do the thing, and you want a small reminder that the old way is gone for good.<\/p>\n<p>Last August a fair chunk went into that folder: most of the <code>wg.Add(1)<\/code> calls I&rsquo;d ever written.<\/p>\n<p>Concurrency has always been Go&rsquo;s headline feature. Goroutines and channels shipped in version one, and they hold up fine. But the patterns most of us built on top of them aged badly. The <code>WaitGroup<\/code> dance. The semaphore you hand-build out of a buffered channel. The <code>time.Sleep<\/code> in a test that quietly hopes the CI runner isn&rsquo;t having a slow morning. None of it was wrong. It was just boilerplate we&rsquo;d all silently agreed to put up with.<\/p>\n<p>Go 1.25 shipped last August and absorbed enough of that boilerplate to be worth a post. So here&rsquo;s a tour of the Go concurrency patterns I actually reach for in 2026: what the standard library finally took over, what moved to <code>errgroup<\/code>, and what&rsquo;s still completely your problem because no API is going to save you from it.<\/p>\n<h2 id=\"the-waitgroup-dance-i-stopped-doing\">The WaitGroup dance I stopped doing<\/h2>\n<p>Here&rsquo;s the pattern every Go developer has typed a few hundred times. Spin up a goroutine per item, track them all with a <code>WaitGroup<\/code>, wait for the lot to finish:<\/p>\n<pre><code class=\"language-go\">var wg sync.WaitGroup\nfor _, url := range urls {\n    wg.Add(1)\n    go func(u string) {\n        defer wg.Done()\n        fetch(u)\n    }(u)\n}\nwg.Wait()\n<\/code><\/pre>\n<p>Three things in there are pure ceremony. The <code>wg.Add(1)<\/code> that has to match a <code>wg.Done()<\/code> living somewhere else. The <code>defer wg.Done()<\/code> you will forget exactly once, and then lose an afternoon to a hang you can&rsquo;t explain. And the <code>func(u string)<\/code> with its <code>(u)<\/code> at the bottom, copying the loop variable so the closure doesn&rsquo;t grab the wrong one.<\/p>\n<p>Go 1.22 already dealt with the third problem by giving every loop iteration its own variable. Go 1.25 dealt with the other two by adding a <code>WaitGroup.Go<\/code> method. Same loop, now:<\/p>\n<pre><code class=\"language-go\">var wg sync.WaitGroup\nfor _, url := range urls {\n    wg.Go(func() {\n        fetch(url)\n    })\n}\nwg.Wait()\n<\/code><\/pre>\n<p><code>Go<\/code> bumps the counter, runs your function in a goroutine, and decrements when it returns. The <a href=\"https:\/\/go.dev\/doc\/go1.25\" rel=\"nofollow noopener\" target=\"_blank\">Go 1.25 release notes<\/a> call it a convenience, which sells it a bit short. The real win is that a mismatched <code>Add<\/code>\/<code>Done<\/code> pair is now impossible to write. If it compiles, the counting is correct. That&rsquo;s a whole class of bug gone, not just a few keystrokes.<\/p>\n<h2 id=\"errgroup-is-still-the-one-i-reach-for-most\">errgroup is still the one I reach for most<\/h2>\n<p><code>WaitGroup.Go<\/code> is great for fire-and-forget work. It has one blind spot: it can&rsquo;t tell you anything went wrong. The functions it runs return nothing, so if <code>fetch<\/code> fails inside one of those goroutines, you find out never.<\/p>\n<p>Most real concurrent work needs to do three things at once: wait for everything to finish, keep the first error, and stop the rest once that error shows up. That&rsquo;s exactly the job <code>golang.org\/x\/sync\/errgroup<\/code> has done for years, and 1.25 didn&rsquo;t try to replace it:<\/p>\n<pre><code class=\"language-go\">g, ctx := errgroup.WithContext(ctx)\nfor _, url := range urls {\n    g.Go(func() error {\n        return fetch(ctx, url)\n    })\n}\nif err := g.Wait(); err != nil {\n    return fmt.Errorf(&quot;fetch batch: %w&quot;, err)\n}\n<\/code><\/pre>\n<p><code>WithContext<\/code> is the part people skip, and they shouldn&rsquo;t. It hands you a derived context that gets cancelled the moment any <code>Go<\/code> function returns a non-nil error. If you actually pass that <code>ctx<\/code> down into <code>fetch<\/code>, one failure stops the other twenty requests still in flight, instead of letting them burn time and rate-limit budget on work whose result you&rsquo;re about to throw away. The <a href=\"https:\/\/pkg.go.dev\/golang.org\/x\/sync\/errgroup\" rel=\"nofollow noopener\" target=\"_blank\">errgroup package docs<\/a> lay out the exact cancellation rules, and they&rsquo;re worth two minutes before you rely on them.<\/p>\n<p>My rule of thumb: if the goroutines can fail, use <code>errgroup<\/code>. If they genuinely can&rsquo;t, <code>WaitGroup.Go<\/code>. I land on the first one far more often than I expected to.<\/p>\n<h2 id=\"bounded-concurrency-without-the-channel-trick\">Bounded concurrency without the channel trick<\/h2>\n<p>One goroutine per item is fine for ten URLs. Try it on fifty thousand and you&rsquo;ll open fifty thousand sockets, or grab fifty thousand database connections, and something downstream will tip over.<\/p>\n<p>For years the fix was a buffered channel used as a counting semaphore:<\/p>\n<pre><code class=\"language-go\">sem := make(chan struct{}, 10)\nfor _, url := range urls {\n    sem &lt;- struct{}{}\n    go func() {\n        defer func() { &lt;-sem }()\n        fetch(url)\n    }()\n}\n<\/code><\/pre>\n<p>It works. It also reads like a riddle. Every time I came back to code like that I had to stop and work out whether sending or receiving was the &ldquo;acquire&rdquo; side. (Sending. It&rsquo;s always sending. I still check.)<\/p>\n<p><code>errgroup<\/code> grew a <code>SetLimit<\/code> method that does the same job and actually says what it means:<\/p>\n<pre><code class=\"language-go\">g, ctx := errgroup.WithContext(ctx)\ng.SetLimit(10)\nfor _, url := range urls {\n    g.Go(func() error {\n        return fetch(ctx, url)\n    })\n}\nif err := g.Wait(); err != nil {\n    return err\n}\n<\/code><\/pre>\n<p>Once ten goroutines are running, the next <code>g.Go<\/code> call blocks until one of them finishes. You get bounded concurrency, error propagation, and context cancellation out of a single object, and whoever reads it next doesn&rsquo;t have to decode a channel idiom first. I set the limit to match whatever the real bottleneck is, usually the connection pool size, rather than a round number that happens to look tidy.<\/p>\n<h2 id=\"testing-concurrency-without-sleep-and-pray\">Testing concurrency without sleep-and-pray<\/h2>\n<p>This is the change I&rsquo;m happiest about, because flaky concurrency tests have eaten more of my life than the actual concurrency bugs ever did.<\/p>\n<p>You know the test. Something is supposed to happen after a timeout, so the test sleeps a little longer than the timeout and then checks:<\/p>\n<pre><code class=\"language-go\">func TestCacheExpiry(t *testing.T) {\n    c := NewCache(50 * time.Millisecond)\n    c.Set(&quot;k&quot;, &quot;v&quot;)\n    time.Sleep(60 * time.Millisecond) \/\/ hope the CI box isn't loaded\n    if _, ok := c.Get(&quot;k&quot;); ok {\n        t.Fatal(&quot;expected key to be expired&quot;)\n    }\n}\n<\/code><\/pre>\n<p>That <code>60ms<\/code> is a bribe. Too short and the test fails on a busy CI runner. Too long and your whole suite drags. There&rsquo;s no correct value, only values that fail a bit less often.<\/p>\n<p>Go 1.25 promoted <code>testing\/synctest<\/code> from an experiment to a real package, and it ends the bribery:<\/p>\n<pre><code class=\"language-go\">func TestCacheExpiry(t *testing.T) {\n    synctest.Test(t, func(t *testing.T) {\n        c := NewCache(50 * time.Millisecond)\n        c.Set(&quot;k&quot;, &quot;v&quot;)\n        time.Sleep(60 * time.Millisecond) \/\/ virtual time: instant and exact\n        if _, ok := c.Get(&quot;k&quot;); ok {\n            t.Fatal(&quot;expected key to be expired&quot;)\n        }\n    })\n}\n<\/code><\/pre>\n<p>The function runs inside what the Go team calls a &ldquo;bubble&rdquo;. Inside the bubble the clock is fake. <code>time.Sleep<\/code> doesn&rsquo;t wait 60 real milliseconds. It advances a virtual clock the instant every goroutine in the bubble is blocked, so the test is deterministic and finishes in microseconds. There&rsquo;s also <code>synctest.Wait<\/code>, which blocks until every other goroutine in the bubble is idle, so you can check state at an exact point without racing it. The <a href=\"https:\/\/go.dev\/blog\/synctest\" rel=\"nofollow noopener\" target=\"_blank\">Go blog&rsquo;s synctest write-up<\/a> and the <a href=\"https:\/\/pkg.go.dev\/testing\/synctest\" rel=\"nofollow noopener\" target=\"_blank\">testing\/synctest docs<\/a> cover the full rules, including which operations are allowed to cross the bubble boundary. One note if you used the experimental version: the old <code>synctest.Run<\/code> is deprecated, and <code>synctest.Test<\/code> is the API to write against now.<\/p>\n<h2 id=\"the-patterns-go-125-didnt-fix\">The patterns Go 1.25 didn&rsquo;t fix<\/h2>\n<p>It would be nice to end with &ldquo;concurrency is solved now&rdquo;. It isn&rsquo;t. A few things are still completely on you, and no shiny method on <code>WaitGroup<\/code> is coming to the rescue.<\/p>\n<p>Goroutine leaks are the big one. <code>errgroup<\/code> waits for the goroutines it started. It has no idea about the one you spawned with a bare <code>go<\/code> and a channel it will block on until the heat death of the process. Every goroutine still needs a clear answer to one question: what guarantees this ends? Usually the answer is a context, which means a context has to actually reach it.<\/p>\n<p>Context propagation is that same discipline wearing a different hat. <code>errgroup.WithContext<\/code> only cancels siblings if you thread its <code>ctx<\/code> all the way down to the call that actually blocks. A context that stops at the first function boundary is decoration. I&rsquo;ve started treating a missing <code>ctx<\/code> argument on an I\/O function the way I treat an ignored error return, a habit I wrote up properly in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/go-error-handling-patterns-i-actually-use-2026\" rel=\"noopener\">Go error handling patterns<\/a>.<\/p>\n<p>Channel ownership hasn&rsquo;t moved either. The goroutine that sends on a channel is the one that closes it, never a receiver, and never two senders racing to get there first. The newer APIs do help sideways here, because the channels you never hand-write can&rsquo;t deadlock on you, and I lean on the range-based patterns from my notes on <a href=\"https:\/\/abrarqasim.com\/blog\/golang-iterators-range-over-func-eight-months-in\" rel=\"noopener\">Go iterators<\/a> for most of the iteration I used to wire up by hand.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Open your largest Go service and run <code>grep -rn \"wg.Add(1)\" .<\/code>. Every hit is a tiny migration. If those goroutines can&rsquo;t fail, swap the block for <code>wg.Go<\/code>. If they can, you probably wanted <code>errgroup<\/code> the whole time. Then pick your slowest concurrency test, the one with a <code>time.Sleep<\/code> you&rsquo;ve quietly resented for months, and move it into a <code>synctest.Test<\/code> bubble. None of this is a rewrite. It&rsquo;s an afternoon of work, and it&rsquo;s the same unglamorous cleanup I end up doing on most <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">client codebases I take on<\/a>: you finish with less code that does the same job and leaves fewer dark corners for bugs to sit in.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Go 1.25 added WaitGroup.Go and a stable testing\/synctest. Here are the Go concurrency patterns I actually use in 2026, and the boilerplate I deleted.<\/p>\n","protected":false},"author":2,"featured_media":271,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Go 1.25 added WaitGroup.Go and a stable testing\/synctest. Here are the Go concurrency patterns I actually use in 2026, and the boilerplate I deleted.","rank_math_focus_keyword":"golang concurrency patterns","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[159,45],"tags":[212,267,46,319,47,131,318],"class_list":["post-272","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-go","category-programming","tag-concurrency","tag-errgroup","tag-go","tag-go-1-25","tag-golang","tag-goroutines","tag-waitgroup"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/272","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=272"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/272\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/271"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=272"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=272"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=272"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}