Skip to content

burrows99/async

Repository files navigation

async — JavaScript-style concurrency for Go

CI Go Reference Go Report Card standard-readme compliant

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.

Table of Contents

Background

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.

Install

go get github.com/burrows99/async

Requires Go 1.23 or newer. There are no dependencies beyond the Go standard library. See the reference docs on pkg.go.dev.

Usage

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)
	})
}

Create and await

// 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"))

Combine — all, race, allSettled, any

// 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
}

Chain — then, finally

// 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() })

Bound the work — timeout, retry

// 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)))

Cancel — signals and controllers

// 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()

Collections — bounded concurrency (p-map / p-queue)

// 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();

Timers — setTimeout / setInterval / debounce / throttle

// 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/dashboard

Packages

The 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, All2All8, 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

Scope

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 synchronizationMutex, 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. Map returns 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.Context propagationasync hides context by design (which is what makes it approachable), so a service that threads request context end-to-end for deadlines and tracing should use context directly. 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.

Cancellation and errors

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; Abort marks 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)
}

Roadmap

  • Core (v0.1): promise (New, WithSignal, Await, All/All2–All8, Race, AllSettled, Timeout, panic containment) + abort (Controller, Signal). ✅ implemented
  • Collections (v0.2): collectionsMap, ForEach, Concurrency, Queue (bounded concurrency over a slice, à la p-map / p-queue). ✅ implemented
  • Utilities (v0.3): promise.Any + AggregateError, promise.Retry, and timersSetTimeout, SetInterval, Debounce, Throttle. ✅ implemented
  • Docs: Coming from JavaScript — every pattern side by side with raw Go, plus the mental-model differences. ✅ implemented

API

Full, generated API documentation lives on pkg.go.dev/github.com/burrows99/async, one page per package:

  • promise — promises and combinators
  • abortAbortController / AbortSignal
  • collectionsMap, ForEach, Queue
  • timersSetTimeout, Debounce, Throttle

Maintainers

@burrows99 (Raunak Burrows).

Contributing

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 All2All8 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 ./...

License

MIT © Raunak Burrows.

About

JavaScript-style concurrency for Go — Promises, async/await, and combinators over goroutines and context.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Sponsor this project

 

Packages

 
 
 

Contributors

Languages