Three jobs need a place to go.
A dispatch desk receives three independent jobs: refresh the catalog, read the orders, and load support totals. Starting them one after another is easy to understand, but it makes the caller wait for the slowest job before any of the others can finish.
JavaScript’s Promise model makes unfinished work a value. Calling an async function gives
you a Promise immediately; await, Promise.all, and Promise.allSettled are choices about when and how to consume those values. The event loop still decides when callbacks
run.
Go separates the nouns. go worker() starts an activity. A channel is a typed path
for values or signals, and receiving, closing, or waiting is how the caller makes completion explicit.
Goroutines may be interleaved or run on different threads; a channel does not promise an ordering
that your protocol has not named.
The fair comparison is not “which syntax is shorter?” It is “what is the unit of work, how does its outcome travel, and who is responsible for finishing the conversation?”
Read the smallest operationTypeScript and Go · one Promise, one result channel
// A Promise is a value that represents one answer which has not arrived yet.
export function startOne(job: Job, work: Work): Promise<number> {
return work(job);
} // A goroutine is execution. It returns no value to its launcher by itself.
func startOne(job Job, work Work) <-chan Outcome {
result := make(chan Outcome, 1)
go func() {
value, err := work(job)
outcome := Outcome{ID: job.ID, Value: value}
if err != nil {
outcome.Error = err.Error()
}
result <- outcome
close(result)
}()
return result
} In both, the caller receives something immediately, but not the answer: a Promise in TypeScript, a one-slot result channel in Go. Returning it keeps the work attached to the caller; starting it and discarding it is how ownership disappears.
Same dispatch, different ownership.
Both examples below can start three jobs. The difference is what each mechanism gives you for free. The Promise version has a value to join. The Go version must define the channel’s message, its close rule, and the coordinator that is allowed to finish it.
Promise as eventual value
Map jobs to Promises, then choose a join: fail fast, keep every settlement, or map to your own result.
- Start
- Call the async function.
- Travel
- Resolve or reject the Promise.
- Finish
- Await or return the join.
Goroutine plus channel
Launch activities, send typed outcomes, and close only after every possible sender has stopped.
- Start
- Use
goto launch work. - Travel
- Send a value or error message.
- Finish
- Receive, range, close, or wait.
| Question | Promise | Goroutine + channel |
|---|---|---|
| What is created? | A value representing one eventual answer. | An activity, plus any channel or result value you add. |
| How do many results join? | all, allSettled, or a custom combinator. | Receive messages until a coordinator closes the channel or a wait completes. |
| What carries failure? | Rejection, or a mapped Result value. | A Result message, error channel, or coordination primitive. |
| What is easy to forget? | A rejection is unowned if the Promise is neither awaited nor returned. | A sender, receiver, or close rule can leak or block a goroutine. |
// Promise.all is a fail-fast join: the first rejection rejects the join,
// but it does not cancel the other Promises that were already started.
export function runFailFast(jobs: Job[], work: Work): Promise<number[]> {
return Promise.all(jobs.map(work));
}
// Catch at the job boundary when every card is useful, even if one card fails.
export async function runSettledBatch(jobs: Job[], work: Work): Promise<Result[]> {
return Promise.all(
jobs.map(async (job): Promise<Result> => {
try {
return { id: job.id, value: await work(job) };
} catch (error) {
return { id: job.id, error: message(error) };
}
})
);
} // Workers send explicit outcomes. The coordinator closes only after every
// possible sender has finished, then ranges until close.
func runWithChannels(jobs []Job, work Work) []Outcome {
results := make(chan Outcome, len(jobs))
var workers sync.WaitGroup
for index, job := range jobs {
workers.Add(1)
go func(index int, job Job) {
defer workers.Done()
value, err := work(job)
outcome := Outcome{Index: index, ID: job.ID, Value: value}
if err != nil {
outcome.Error = err.Error()
}
results <- outcome
}(index, job)
}
go func() {
workers.Wait()
close(results)
}()
outcomes := make([]Outcome, 0, len(jobs))
for outcome := range results {
outcomes = append(outcomes, outcome)
}
sort.Slice(outcomes, func(i, j int) bool { return outcomes[i].Index < outcomes[j].Index })
return outcomes
} Hold the shape fixed; change the protocol.
Choose one work shape and compare the two models. Look for the thing that represents unfinished work, the path that carries an answer, and the signal that tells the consumer it is done. On failure, ask whether siblings stop automatically. They do not.
Keep the work shape fixed. Change the execution model.
map the jobs to Promises before waiting for any one result.
Each Promise settles independently; the combinator chooses the contract.
Promise.all preserves input order and fails fast; allSettled keeps every outcome.
Choose fail-fast, all-settled, or an explicit Result per job.
Watch for Promise.all does not cancel the other work when one Promise rejects.
Read the full dispatchTypeScript and Go · same jobs, different protocol
One answer: return a Promise or send one Outcome on a buffered channel.
// A Promise is a value that represents one answer which has not arrived yet.
export function startOne(job: Job, work: Work): Promise<number> {
return work(job);
} // A goroutine is execution. It returns no value to its launcher by itself.
func startOne(job Job, work Work) <-chan Outcome {
result := make(chan Outcome, 1)
go func() {
value, err := work(job)
outcome := Outcome{ID: job.ID, Value: value}
if err != nil {
outcome.Error = err.Error()
}
result <- outcome
close(result)
}()
return result
} Choose what the caller actually needs.
There is no universal winner. A browser page already has Promise-based APIs. A Go service already has goroutines and channels. The design decision is still portable: name the task lifetime, the message shape, the completion signal, and the policy for one failed worker.
Keep work attached to the owner that can finish it.
For a fixed set of independent reads, start the work together, join it once, and return the outcomes in the order the caller expects. In TypeScript, make the Promise join visible and handle rejections at the operation boundary. In Go, make the results channel typed, size it or keep a receiver ready, and close it from the coordinator, not from a worker that cannot know about its peers.
If work streams for an unknown length, the channel or async iterator needs a completion protocol. If work can outlive the request, neither a loose Promise nor a loose goroutine is enough: you need a lifetime owner, cancellation, and a place to observe failure.
Who launches it?
Keep the caller able to name the work it now owns.
What message moves?
Carry success, failure, and identity in a shape the receiver can use.
Who closes the loop?
Join, close, or return so no work becomes invisible background activity.
A simple dashboard uses Promise.all when the page needs all three cards as one snapshot.
type Card = { title: string; value: string };
async function loadDashboard(): Promise<Card[]> {
const [sales, orders, support] = await Promise.all([
getCard('sales'),
getCard('orders'),
getCard('support')
]);
return [sales, orders, support];
}
export function Dashboard() {
return <DashboardCards load={loadDashboard} />;
}
declare function getCard(name: string): Promise<Card>;
declare function DashboardCards(props: { load: () => Promise<Card[]> }): JSX.Element;
Build services or UIs?You already coordinate unfinished work.
Where it already is in your components
A Promise.all in a loader, a for await loop over a stream, a Go worker
pool, and a channel used to signal shutdown are all the same review question: who owns the work
and how does the owner learn that it ended?
When you have to own it
When one request fans out to several calls, write down whether partial results are useful, whether ordering matters, and what a failed child does to its siblings. Then choose the language primitive that keeps those answers close to the call site.
The browser gives you Promises; the ownership problem remains.
Promise.all
Good for a fixed snapshot when every result is required before the view is complete.
Promise.allSettled
Useful when cards are independent, but map each settlement to a keyed state before rendering.
Async iterables
A sequence needs a next-value protocol and a separate completion signal, much like a channel.
The primitive cannot own what you never named.
Promise.all is not cancellation
When one Promise rejects, the joined Promise rejects. The other operations may still be
running and may still mutate or consume resources. Pass an AbortSignal or use a
higher-level lifetime owner when stopping siblings matters.
A channel is not a bag of results
The receiver gets messages in send order, which may be completion order. If the caller needs input order, include an index. If the receiver ranges forever, find the coordinator responsible for close.
Errors need a path
A rejected Promise is easy to drop with an unhandled call. A Go worker can return an error that no one receives. Put the error in the same result protocol when the caller must associate it with a job, and log or translate only where that owner has useful context.
Concurrency can amplify load
Starting three tasks together reduces waiting for independent work; starting three thousand together can overload the database. Bounded parallelism and backpressure are the next decisions once the basic work protocol is correct.
Choose the smallest honest concurrency contract.
Use a Promise when the thing your caller needs is one eventual value and the surrounding APIs already use Promise composition. Use goroutines and channels when independent activities need to exchange many typed messages or when the service’s lifetime and coordination are clearer as explicit protocols.
Return the Promise or one result channel.
Keep the work attached to the caller that awaits or receives it.
Choose the join and order.
Use a combinator or channel coordinator that says when and how collection ends.
Carry it as data and choose siblings.
Neither primitive cancels every other task by magic.
Make unfinished work visible.
Promises and goroutines are not interchangeable spellings. A Promise gives you a value to compose. A goroutine gives you execution; channels and coordination give that execution a conversation. Once you name the message, completion, failure, and lifetime, the language choice gets much less mysterious. The primitive starts the conversation; ownership is what makes it safe.
- Why
- Coordinate independent work without hiding its lifetime.
- What
- Use an eventual value or an explicit message protocol.
- Constraint
- Define completion, ordering, failure, and cancellation before the first child starts.
- Fallback
- Keep the protocol small: one result per job, one error path, one join. Test that boundary before adding coordination.
- Reconsider when
- The result becomes a stream, the load needs a bound, or the owner changes.
Connections to follow nextRelated lessons
- Backpressure and queues when producers can outrun consumers and pending work needs a capacity policy.
- Race conditions in UI when results can arrive after the user has moved on.
- Event loop vs. scheduler for what runs next once the work has started.
- Cancellation propagation when a parent must stop the children it started.