JavaScript-style concurrency for Go — Promises, async/await, and combinators over goroutines and context.
async brings the concurrency vocabulary you already know from JavaScript —
Promise, await, Promise.all, AbortController — to Go, so orchestration
code reads like the JS it mirrors while goroutines and context.Context run
underneath.
// JS: const [user, orders, recs] = await Promise.all([getUser(1), getOrders(1), getRecs(1)]);
user, orders, recs, err := promise.All3(getUser(1), getOrders(1), getRecs(1))It is an onboarding ramp and a boilerplate-killer for the 90% case — not a
replacement for channels. When you outgrow it, drop back to goroutines and
select; async only accepts and returns standard types, so it interoperates
rather than competes. It has zero dependencies beyond the standard library.
- Background
- Install
- Usage
- Packages
- Scope
- Cancellation and errors
- Roadmap
- API
- Maintainers
- Contributing
- License
JavaScript is one of the largest migration paths into Go, and developers arrive
with a well-formed mental model for concurrency: Promises, async/await, and
combinators like Promise.all. In Go they must relearn concurrency from
goroutines, channels, sync.WaitGroup, and select — which slows onboarding
and produces buggy code (leaked goroutines, unrecovered panics, missing
cancellation).
async maps that JavaScript model onto idiomatic Go: generics for type safety,
(T, error) returns instead of rejection chains, panic containment by default,
and cancellation modelled on AbortController/AbortSignal. The public API uses
the JavaScript and npm vocabulary throughout (Promise.all, p-map's
{ concurrency }, p-queue's add/onIdle, lodash debounce/throttle), so
the surface is familiar while the semantics stay Go-shaped.
New to Go from JavaScript? Start with Coming from JavaScript —
side-by-side JS, raw Go, and async, plus the mental-model differences that will
bite you if you assume Go is just JavaScript with types.
go get github.com/burrows99/asyncRequires Go 1.23 or newer. There are no dependencies beyond the Go standard library. See the reference docs on pkg.go.dev.
Put the promise.New wrapping in your function definitions — exactly where JS
puts the async keyword — and return a *promise.Promise[T]. Then every call
site reads like the JavaScript it mirrors:
import (
"github.com/burrows99/async/abort"
"github.com/burrows99/async/collections"
"github.com/burrows99/async/promise"
"github.com/burrows99/async/timers"
)
// An "async function": calling getUser(1) returns a Promise, just like
// `async function getUser(id) { ... }` does in JS. The rest of the examples
// assume getOrders, getRecs, etc. follow the same pattern.
func getUser(id int) *promise.Promise[User] {
return promise.New(func() (User, error) {
return db.FindUser(id)
})
}// JS: const v = await doWork();
v, err := promise.Await(getUser(1)) // or: v, err := getUser(1).Await()
// JS: Promise.resolve(42) / Promise.reject(new Error("no"))
ready := promise.Resolve(42)
bad := promise.Reject[int](errors.New("no"))// JS: const users = await Promise.all([getUser(1), getUser(2), getUser(3)]);
users, err := promise.All(getUser(1), getUser(2), getUser(3)) // []User, fail-fast
// JS: const [user, orders] = await Promise.all([getUser(1), getOrders(1)]); // mixed types
user, orders, err := promise.All2(getUser(1), getOrders(1))
user, orders, recs, err := promise.All3(getUser(1), getOrders(1), getRecs())
// JS: const first = await Promise.race([primary(), replica()]);
winner, err := promise.Race(primary(), replica())
// JS: const results = await Promise.allSettled([getUser(1), getUser(2)]); // never fails
for i, r := range promise.AllSettled(getUser(1), getUser(2)) {
if r.OK() {
fmt.Println(i, r.Value)
} else {
fmt.Println(i, "failed:", r.Reason)
}
}
// JS: const ok = await Promise.any([mirrorA(), mirrorB()]); // first success wins
value, err := promise.Any(mirrorA(), mirrorB())
var agg *promise.AggregateError
if errors.As(err, &agg) {
// every input rejected; agg.Errors holds each reason
}// JS: getUser(1).then(u => u.Name)
nameP := promise.Then(getUser(1), func(u User) (string, error) { return u.Name, nil })
// JS: doWork().finally(() => cleanup())
p := getUser(1).Finally(func() { cleanup() })// JS: await fetch(url, { signal: AbortSignal.timeout(200) });
v, err := promise.Await(promise.Timeout(slowCall(), 200*time.Millisecond))
if errors.Is(err, promise.ErrTimeout) {
// deadline hit
}
// JS: await pRetry(flaky, { retries: 2 });
res, err := promise.Await(promise.Retry(flaky,
promise.Attempts(3), promise.ExpBackoff(100*time.Millisecond)))// A cancellable "async function" — the Go analogue of async fn ({ signal }).
func fetchThing(id int) *promise.Promise[Thing] {
return promise.WithSignal(func(sig *abort.Signal) (Thing, error) {
return client.Get(sig.Context(), id) // sig.Context() bridges to any ctx-aware API
})
}
// JS: const c = new AbortController(); fetchThing(c.signal); c.abort();
p := fetchThing(1)
p.Abort() // AbortController.abort()
_, err := p.Await() // abort.ErrAborted
// A standalone controller can drive work you build yourself:
c := abort.NewController()
task := promise.New(func() (int, error) {
select {
case <-time.After(time.Second):
return 1, nil
case <-c.Signal().Done():
return 0, c.Signal().Reason()
}
})
c.Abort()// JS: const users = await pMap(ids, getUser, { concurrency: 10 });
users, err := collections.Map(ids, getUser, collections.Concurrency(10))
// Side effects only, like Promise.all(items.map(fn)); fn returns a Promise.
err = collections.ForEach(ids, sendWelcome, collections.Concurrency(5))
// JS: const q = new PQueue({ concurrency: 4 });
q := collections.NewQueue[Report](4)
for _, id := range ids {
report := q.Add(func() (Report, error) { return build(id) }) // returns a Promise
_ = report
}
q.OnIdle() // await q.onIdle();// JS: const t = setTimeout(fire, 200); clearTimeout(t);
t := timers.SetTimeout(fire, 200*time.Millisecond)
timers.ClearTimeout(t)
// JS: const iv = setInterval(tick, 1000); clearInterval(iv);
iv := timers.SetInterval(tick, time.Second)
timers.ClearInterval(iv)
// JS: const save = _.debounce(persist, 300); save(); save(); // runs once
save, _ := timers.Debounce(persist, 300*time.Millisecond)
save()
save()
// JS: const onScroll = _.throttle(handle, 100);
onScroll, _ := timers.Throttle(handle, 100*time.Millisecond)
onScroll()For a full, runnable showcase of every pattern — written to read like JavaScript
line for line — see examples/dashboard:
go run ./examples/dashboardThe library is organized by JavaScript concept, so each import reads like the API it provides:
| Package | Mirrors | Provides |
|---|---|---|
promise |
Promise |
New, Resolve, Reject, WithSignal, Await, Then, Finally, All, All2–All8, Race, AllSettled, Any + AggregateError, Retry, Timeout |
abort |
AbortController / AbortSignal |
NewController, Controller, Signal |
collections |
p-map / p-queue |
Map, ForEach, Concurrency, Queue |
timers |
setTimeout / lodash |
SetTimeout, SetInterval, Debounce, Throttle |
async is deliberately a slice of concurrency, not the whole thing. It does one
job well — fan out a known set of tasks, then gather the results, with
optional concurrency limits, retries, timeouts, and cancellation (All, Any,
Race, Map, Queue, Retry). For that shape it's a real
errgroup/semaphore boilerplate-killer, and it names that job honestly rather than
claiming to be a full toolkit.
Reach for raw goroutines, channels, select, and sync when you need:
- Shared-state synchronization —
Mutex,atomic,Once,sync.Map. A promise library doesn't replace a mutex, and real code needs them constantly. - Streaming, pipelines, and fan-in — producer→consumer, or results consumed
as they arrive.
Mapreturns everything at once, in order — it doesn't stream. - General
select— waiting on many dynamic events, non-blocking checks, or ticker-driven coordination. - Deep
context.Contextpropagation —asynchides context by design (which is what makes it approachable), so a service that threads request context end-to-end for deadlines and tracing should usecontextdirectly.signal.Context()bridges at a boundary, but it is not full propagation.
Everything here returns and accepts only standard types, so you can mix the two
freely: use async for the fan-out/gather 90%, and drop to channels the moment
the shape changes. Coming from JavaScript? The
migration guide spells out exactly where the
ramp ends.
Go cannot preempt a goroutine, and neither can JavaScript preempt a running
function. That is why JS cancellation is opt-in: you thread an AbortSignal into
fetch(url, { signal }), and work that ignores the signal keeps running.
async works the same way.
- Plain work uses
promise.New(func() (T, error))— no signal. It cannot be interrupted mid-flight;Abortmarks it aborted for awaiters, but the function runs to completion. - Cancellable work uses
promise.WithSignal(func(signal *abort.Signal) (T, error))and watches the signal — the Go analogue of accepting{ signal }.
// JS: const c = new AbortController(); doWork(c.signal); c.abort();
c := abort.NewController()
p := promise.New(func() (T, error) { /* … watch c.Signal().Done() … */ })
c.Abort()signal.Context() bridges to the standard library — pass it to any
context-aware Go API (database, HTTP, gRPC) so the underlying call is cancelled
too. It is the one place a context.Context surfaces, exactly as a signal
surfaces only at the fetch call in JavaScript.
Errors stay Go-shaped. Await returns (T, error); there is no .Catch, and
errors compose with errors.Is/errors.As. A panic never crashes the process:
every function runs under recover(), and a panic becomes a
*promise.PanicError carrying the value and a stack trace, returned from Await
like any other error.
p := promise.New(func() (int, error) { panic("boom") })
_, err := promise.Await(p)
var pe *promise.PanicError
if errors.As(err, &pe) {
log.Printf("recovered: %v\n%s", pe.Value, pe.Stack)
}- Core (v0.1):
promise(New, WithSignal, Await, All/All2–All8, Race, AllSettled, Timeout, panic containment) +abort(Controller, Signal). ✅ implemented - Collections (v0.2):
collections—Map,ForEach,Concurrency,Queue(bounded concurrency over a slice, à la p-map / p-queue). ✅ implemented - Utilities (v0.3):
promise.Any+AggregateError,promise.Retry, andtimers—SetTimeout,SetInterval,Debounce,Throttle. ✅ implemented - Docs: Coming from JavaScript — every pattern side by side with raw Go, plus the mental-model differences. ✅ implemented
Full, generated API documentation lives on pkg.go.dev/github.com/burrows99/async, one page per package:
promise— promises and combinatorsabort—AbortController/AbortSignalcollections—Map,ForEach,Queuetimers—SetTimeout,Debounce,Throttle
@burrows99 (Raunak Burrows).
Issues and pull requests are welcome. Ask questions or propose changes via GitHub Issues or Discussions. PRs are accepted; small, focused changes are easiest to review, and no CLA or commit sign-off is required.
The All2…All8 variants in promise/alln.go are generated — Go generics have
no variadic type parameters. Edit internal/gen, then regenerate:
go generate ./...Before opening a PR, make sure the same checks CI runs pass locally:
gofmt -l .
go vet ./...
go test -race ./...MIT © Raunak Burrows.
