You already think in Promises, async/await, and Promise.all. This guide
maps that model onto async, shows the raw-Go boilerplate it replaces, and —
just as importantly — calls out the places where Go is not JavaScript, so the
ramp doesn't quietly become a trap.
- The 60-second map
- Side by side
- Mental-model differences you must internalize
- When to put the library down and use channels
| JavaScript | async |
|---|---|
new Promise(fn) / calling an async function |
promise.New(fn) |
Promise.resolve(v) / Promise.reject(e) |
promise.Resolve(v) / promise.Reject(e) |
await p |
promise.Await(p) or p.Await() |
p.then(fn) |
promise.Then(p, fn) |
p.finally(fn) |
p.Finally(fn) |
p.catch(fn) |
— use the error from Await (see Errors) |
Promise.all([...]) |
promise.All(...) / promise.All2–All8 for mixed types |
Promise.race([...]) |
promise.Race(...) |
Promise.allSettled([...]) |
promise.AllSettled(...) |
Promise.any([...]) |
promise.Any(...) → AggregateError if all fail |
pMap(items, fn, { concurrency }) |
collections.Map(items, fn, collections.Concurrency(n)) |
new PQueue({ concurrency }) |
collections.NewQueue(n) (Add, OnIdle) |
pRetry(fn, { retries }) |
promise.Retry(fn, promise.Attempts(n)) |
AbortSignal.timeout(ms) |
promise.Timeout(p, d) |
new AbortController() / signal |
abort.NewController() / abort.Signal |
setTimeout / setInterval |
timers.SetTimeout / timers.SetInterval |
_.debounce / _.throttle |
timers.Debounce / timers.Throttle |
Each pattern below is shown three ways: the JavaScript you'd write, the
raw Go you'd otherwise have to write, and the async version.
// JavaScript
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);// Raw Go: errgroup + shared vars, or channels + a struct
var user User
var orders []Order
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { var e error; user, e = getUser(ctx, id); return e })
g.Go(func() error { var e error; orders, e = getOrders(ctx, id); return e })
if err := g.Wait(); err != nil { /* ... */ }// async — heterogeneous join keeps each type
user, orders, err := promise.All2(getUser(id), getOrders(id))// JavaScript
const users = await pMap(ids, getUser, { concurrency: 10 });// Raw Go: semaphore channel + WaitGroup + indexed results
results := make([]User, len(ids))
sem := make(chan struct{}, 10)
g, ctx := errgroup.WithContext(ctx)
for i, id := range ids {
i, id := i, id
g.Go(func() error {
sem <- struct{}{}; defer func() { <-sem }()
u, err := getUser(ctx, id)
results[i] = u
return err
})
}
err := g.Wait()// async
users, err := collections.Map(ids, getUser, collections.Concurrency(10))// JavaScript
const res = await pRetry(flaky, { retries: 2 });// Raw Go: loop + backoff math + context checks
var res T
var err error
delay := 100 * time.Millisecond
for attempt := 1; attempt <= 3; attempt++ {
if res, err = flaky(ctx); err == nil { break }
if attempt < 3 {
select {
case <-time.After(delay):
case <-ctx.Done(): return ctx.Err()
}
delay *= 2
}
}// async
res, err := promise.Await(promise.Retry(flaky, promise.Attempts(3), promise.ExpBackoff(100*time.Millisecond)))// JavaScript
const c = new AbortController();
const p = fetchThing({ signal: c.signal });
c.abort();// async — WithSignal opts the work into cancellation, like { signal }
c := abort.NewController()
p := promise.WithSignal(func(signal *abort.Signal) (Thing, error) {
return fetchThing(signal.Context()) // signal.Context() bridges to any ctx-aware API
})
c.Abort()The orchestration reads like JavaScript. The runtime does not. These are the things that will bite you if you assume otherwise.
JavaScript runs your code on a single thread and interleaves work through the
event loop; await yields to the microtask queue in a well-defined order. Go has
none of that. Goroutines run on multiple OS threads, truly in parallel, and
the scheduler can switch between them at almost any point. There is no
queueMicrotask, and no ordering guarantee between two concurrent tasks unless
you create one with a channel or a lock.
There is no try/catch around await, and no .catch. Every Await returns
(value, error):
v, err := promise.Await(p)
if err != nil {
// handle it — errors.Is / errors.As work through the chain
}A panic (Go's version of a thrown exception) is contained, not propagated: it
comes back as a *promise.PanicError, so one task crashing never takes down the
process — the survivable-rejection guarantee you're used to, made explicit.
Because goroutines run in parallel, shared mutable state is a data race. In JS a
single-threaded model let you mutate freely between awaits; in Go you cannot.
If two tasks touch the same variable, guard it with sync.Mutex or sync/atomic.
This library does not — and cannot — hide that.
Neither Go nor JavaScript can forcibly stop a running function. In JS you thread
a signal into fetch; in async you use promise.WithSignal and watch the
signal. Plain promise.New work cannot be interrupted mid-flight — Abort
marks it aborted for awaiters, but the function runs to completion. Design
long-running tasks to accept a signal.
Go methods can't add type parameters, so Then is a package-level function.
Cross-type chains read inside-out:
// JS: getUser(id).then(u => u.Name).then(greet)
promise.Then(promise.Then(getUser(id), userName), greet)In practice, prefer straight-line Go — Await, then act on the value — over deep
Then chains. The chaining exists for familiarity, not because it's the idiom.
There is no "floating promise" warning, but there is also no free lunch: a
promise you never await still runs its function to completion and then exits. It
won't leak, but it won't stop on its own either. If you start work you might not
need, start it with WithSignal and Abort it.
async is built for one shape: fan out a known set of tasks, then gather the
results (with optional concurrency limits, retries, timeouts, and
cancellation). That covers a large fraction of everyday concurrency. It is not
a general toolkit, and reaching past it is expected, not a failure.
Drop to raw goroutines, channels, select, and sync when you need:
- Streaming / pipelines — producer→consumer, or results consumed as they arrive rather than all at once.
- Fan-in / dynamic routing — merging many sources,
selectover several channels, non-blocking checks. - Long-lived coordination — background workers, tickers driving state,
request-scoped
context.Contextthreaded deep through a service. - Shared-state synchronization —
Mutex,RWMutex,atomic,Once,Cond,singleflight.
The library returns and accepts only standard types, so you can mix the two
freely: use async for the 90% that's fan-out/gather, and reach for channels the
moment the shape changes.