← Concepts & practices
Choice Concurrency, scheduling, and delivery

Promises vs. goroutines & channels

Compare the models honestly.

A Promise is a value for work that has not finished. A goroutine is work that has started, and a channel is a path between activities. They can both fan out three jobs and gather three answers, but they make different things visible, and leave different things for you to design.

The judgment to keep

Choose the model that makes ownership, communication, completion, and failure explicit. Promises are eventual values; goroutines are execution. Channels carry the values and the completion protocol.

TypeScriptGo One dispatch desk · two concurrency models
Start with the work

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
dispatch.ts · one Promise
// 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);
}
dispatch.go · one result channel
// 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.

Two models

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.

JavaScript / TypeScript

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.
Go

Goroutine plus channel

Launch activities, send typed outcomes, and close only after every possible sender has stopped.

Start
Use go to launch work.
Travel
Send a value or error message.
Finish
Receive, range, close, or wait.
What the mechanism makes you name
QuestionPromiseGoroutine + 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.
TypeScript · promise join
dispatch.ts · Promise joins
// 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) };
			}
		})
	);
}
Go · channel join
dispatch.go · channel join
// 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
}
Move the work

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.

One dispatch desk

Keep the work shape fixed. Change the execution model.

Illustrative local model
An array of Promises represents the fixed set of work. Promises · Fan out, then gather
01map jobs → Promises
02all are pending
03all / allSettled → joined result
Start

map the jobs to Promises before waiting for any one result.

Communication

Each Promise settles independently; the combinator chooses the contract.

Join / completion

Promise.all preserves input order and fails fast; allSettled keeps every outcome.

Failure

Choose fail-fast, all-settled, or an explicit Result per job.

The join operator is part of the failure and ordering design.

Watch for Promise.all does not cancel the other work when one Promise rejects.

The controls change a local model; they do not start real workers or network calls.
Read the full dispatchTypeScript and Go · same jobs, different protocol

One answer: return a Promise or send one Outcome on a buffered channel.

TypeScriptReading
dispatch.ts
// 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);
}
GoAlongside
dispatch.go
// 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
}
Make the protocol explicit

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.

A browser page needs four independent cards before it shows the complete snapshot.
A Go server should process jobs and stream whichever result finishes next.
One worker fails while its siblings are still running.
Feedback stays on this page; it is not saved.
A production dispatch

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.

Start

Who launches it?

Keep the caller able to name the work it now owns.

Travel

What message moves?

Carry success, failure, and identity in a shape the receiver can use.

Finish

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.

ReactAlready in your code
textbook.tsx · Promise.all
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.

Recognize it in UI code

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.

See what happens when the consumer is slower ↗
The parts to watch

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.

Make the call

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.

One answer

Return the Promise or one result channel.

Keep the work attached to the caller that awaits or receives it.

Many answers

Choose the join and order.

Use a combinator or channel coordinator that says when and how collection ends.

Failure

Carry it as data and choose siblings.

Neither primitive cancels every other task by magic.

Take the idea with you

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
Explore more concepts & practices →