the go design
The same machine, in Go.
Asyncify runs in production on TypeScript. This page is the Go design — the same architecture, re-expressed in Go's concurrency model, concept by concept. Same laws, different primitives.
01
The mapping
| Asyncify in production | The Go expression |
|---|---|
| Worker processes consuming BullMQ lanes | Worker pools of goroutines — one pool per channel × priority, each sized independently, so p0's pool can never be starved by a p2 blast |
| The 50,000-job backpressure watermark | A bounded buffered channel — a blocking send IS the backpressure; the float valve becomes a language feature |
| Step delays + digest windows | time.Timer + select |
| The graceful drain on deploy | context cancellation + sync.WaitGroup |
| Per-tenant rate limits (50/s, overflow to 5×) | A token bucket per tenant (golang.org/x/time/rate) |
| Circuit breaker per provider |
A sync.Mutex-guarded struct: consecutive-failure counter +
open-until timestamp
|
| WebSocket gateway | One goroutine per connection + a hub goroutine owning the registry, fed by channels |
| Open-pixel / webhook status updates | Plain HTTP handlers writing Postgres — language-agnostic by design |
02
Goroutines: a worker is a goroutine, not a process
A goroutine is a function running concurrently, cheap enough (≈2KB stack) to start thousands.
In Asyncify-in-Go: today each delivery lane is consumed by worker processes; in Go, a lane is consumed by a POOL of goroutines — spawn 32 for email-p1, 8 for sms-p1, 64 for inapp-p0, tune per lane. Where Node scales lanes by processes, Go scales them by numbers in one process.
┌─ goroutine #1 ──► Resend
deliver-email-p1 ─────┼─ goroutine #2 ──► Resend
(channel) ├─ ...
└─ goroutine #32 ─► Resend
│
▼
one status writer goroutine
(fan-in over a results channel)
func startPool(ctx context.Context, n int, jobs <-chan Job, wg *sync.WaitGroup) {
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // shutdown: stop taking work
case job, ok := <-jobs:
if !ok {
return // lane closed: drain complete
}
deliver(ctx, job)
}
}
}()
}
}
say it like this
“a delivery worker stops being a process and becomes a number — p0 gets its own pool, so a million-row broadcast physically cannot occupy a transactional worker. Same law as my production system, enforced by the runtime instead of the deployment.”
03
Channels, buffered vs unbuffered: the watermark becomes the language
A channel is a typed pipe between goroutines; an unbuffered channel hands off synchronously (sender waits for receiver), a buffered channel queues up to its capacity and THEN blocks the sender.
In Asyncify-in-Go — the star mapping. Today the broadcast streamer asks Redis "how many jobs are waiting?" before every page and sleeps above 50,000. In Go, the entire mechanism is one line:
jobs := make(chan Job, 50_000) // the watermark IS the buffer size
When the buffer is full, jobs <- job BLOCKS. The streamer pauses not
because it checked a counter, but because the language refused the send until a
worker made room. The sawtooth behavior is identical; the code implementing it
disappears.
Unbuffered contrast: the WS hub hands a message to a connection's writer goroutine over an UNBUFFERED channel — a synchronous handoff, so a slow client exerts backpressure one message at a time instead of hoarding a queue.
STREAMER make(chan Job, 50_000) WORKER POOL
reads 100 subscribers ┌────────────────────────┐
from Postgres (keyset) ───► │ [job][job][job] ... 50k│ ◄─── each goroutine:
│ └────────────────────────┘ job := <-jobs
│ buffer full? the SEND BLOCKS —
└─ the streamer sleeps inside the language,
resumes the instant a worker receives
func streamBroadcast(ctx context.Context, tenantID string, jobs chan<- Job) error {
cursor := ""
for {
subs, next, err := pageSubscribers(ctx, tenantID, cursor, 100) // keyset: WHERE id > cursor LIMIT 100
if err != nil || len(subs) == 0 {
return err
}
for _, s := range subs {
select {
case jobs <- newJob(s): // blocks at 50k in flight: backpressure for free
case <-ctx.Done():
return ctx.Err()
}
}
cursor = next
}
}
say it like this
“in production I implemented backpressure as a watermark check against Redis. In Go I'd delete that code — a bounded buffered channel IS a watermark. Blocking send is the feature.”
04
select: one goroutine, several doors
select blocks on several channel operations at once and takes whichever
is ready first — concurrency's switch statement.
In Asyncify-in-Go, three real uses:
- Every pool worker: job channel vs
ctx.Done()(section 2's loop). - The digest window: items arriving vs the 120s timer vs shutdown.
- The breaker's half-open probe: timer-gated single retry.
┌──────────────────────────────┐
incoming deals ──►│ │
(channel) │ one goroutine per OPEN │──► on timer: render
windowTimer.C ───►│ digest window, select-ing │ {{digest_items}},
(120s) │ on three doors │ send ONE email
ctx.Done() ──────►│ │
└──────────────────────────────┘
func digestWindow(ctx context.Context, incoming <-chan Item, window time.Duration) {
timer := time.NewTimer(window) // the caller starts this goroutine on the
// window's FIRST item — same anchor rule as production
defer timer.Stop()
var batch []Item
for {
select {
case item := <-incoming: // a later deal merges into the open window
batch = append(batch, item)
case <-timer.C: // 120s up: drain, render, send once
sendDigest(render(batch))
return
case <-ctx.Done(): // shutdown mid-window: persist, never lose
persist(batch)
return
}
}
}
say it like this
“the digest window is a goroutine selecting on three doors: another item, the timer, or shutdown. The shutdown door persists the batch — degrade to later, never to lost, same law as production.”
05
WaitGroup + Mutex: the drain and the shared counter
sync.WaitGroup counts live goroutines so shutdown can wait for them;
sync.Mutex serializes access to state that several goroutines must
mutate.
WaitGroup in Asyncify-in-Go — the graceful drain. On deploy, production today stops intake and gives in-flight sends a grace period. In Go:
// Two separate signals, or the grace period is a lie: closing the jobs
// channel stops INTAKE (workers drain what's buffered, then exit via the
// `ok` check in their loop); hardCancel aborts in-flight sends — and it
// fires only if the ceiling is hit.
close(jobs)
done := make(chan struct{})
go func() {
wg.Wait() // every in-flight deliver() returns
close(done)
}()
select {
case <-done: // clean drain — nothing was aborted
case <-time.After(30 * time.Second):
hardCancel() // ceiling reached: the context tree aborts stragglers
}
Mutex in Asyncify-in-Go — the circuit breaker. Jobs flow by message-passing (channels), but the breaker is genuinely SHARED state: every worker in a lane consults the same failure counter. Go's proverb is "share memory by communicating" — and where you deliberately don't, you lock:
type Breaker struct {
mu sync.Mutex
failures int // CONSECUTIVE transient failures — OnSuccess zeroes it
openUntil time.Time // zero = closed
probing bool // half-open: exactly one probe in flight
}
func (b *Breaker) Allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
if time.Now().Before(b.openUntil) {
return false // open: fail fast — the failover chain tries the next provider
}
if b.openUntil.IsZero() {
return true // closed: requests flow
}
if b.probing {
return false // half-open: someone else holds the single probe
}
b.probing = true // this request becomes the probe
return true
}
func (b *Breaker) OnSuccess() {
b.mu.Lock()
defer b.mu.Unlock()
b.failures, b.openUntil, b.probing = 0, time.Time{}, false // close fully
}
func (b *Breaker) OnFailure() {
b.mu.Lock()
defer b.mu.Unlock()
if b.probing { // the half-open probe failed: straight back to open
b.probing = false
b.openUntil = time.Now().Add(30 * time.Second)
return
}
if b.failures++; b.failures >= 5 { // 5 CONSECUTIVE → open 30s (prod thresholds)
b.openUntil = time.Now().Add(30 * time.Second)
b.failures = 0
}
}
say it like this
“channels where work FLOWS, a mutex where state is genuinely shared — the breaker is the honest example: every worker must see the same failure count, so it's a locked struct, not a message.”
06
context: timeouts and cancellation as a tree
A context carries deadlines and cancellation down a call tree — cancel
the parent and everything beneath it stops.
Three real uses in Asyncify-in-Go:
-
Per-send provider timeout — today a 10s HTTP timeout per provider call; in Go,
context.WithTimeoutwraps each send, and the HTTP client aborts mid-flight when it expires. -
Shutdown as a tree — one
cancel()at main propagates through streamer → pools → in-flight HTTP calls. The same signal that stops intake aborts stragglers; the WaitGroup drain (section 5) collects the bodies. - The eval runner — a canceled run stops every scenario goroutine with one call, instead of each checking a flag.
func deliver(ctx context.Context, job Job) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := providerSend(ctx, job.Message); err != nil {
classify(err) // permanent vs transient — same taxonomy as production
}
}
hardCancel() at main
│
┌────────┼──────────┐
│ │ │
streamer pools ws hub
page read in-flight per-conn
aborts sends writers
abort close
say it like this
“shutdown isn't a broadcast flag I check in loops — it's a tree. One cancel at the root reaches every in-flight HTTP call, and the WaitGroup tells me when the tree is empty.”
07
The worker pool pattern: the whole machine
The capstone: the full event journey, Go edition.
ACME'S BACKEND
│ POST /v1/events/trigger
▼
API (net/http handler goroutine per request)
• auth → tenant • token bucket per tenant (x/time/rate):
• dedupe (tenant, txn) within limit → lane · burst → overflow chan · 5× → 429
• EVENT ROW → Postgres outbox (unchanged — durability is not a language feature)
• job → lane channel, 202 in ms
▼
LANE CHANNELS (bounded buffers = watermarks)
email-p0 ══► pool (8) ─┐
email-p1 ══► pool (32) ├─ each worker: select { job / ctx.Done }
email-p2 ══► pool (16) │ status guard → render → deliver(ctx, 10s)
inapp-p1 ══► pool (64) ─┘ → breaker.OnFailure / OnSuccess → row update
▼
FEEDBACK (plain HTTP handlers — webhooks, open pixel → Postgres)
DRAIN (deploy): close(jobs) → pools finish the buffer → wg.Wait() ≤ 30s
→ hardCancel() only if the ceiling hits
say it like this
“one pool per lane, each a number I tune; bounded channels as watermarks; context as the kill tree; WaitGroup as the drain. The architecture is unchanged from my production system — Go just gives three of its mechanisms to the runtime.”
08
What Go does NOT replace
- Channels are in-process and die with the process. A channel is not a queue you can restart into. Redis (or any durable broker) stays for cross-process, crash-surviving handoff; channels orchestrate INSIDE a worker binary. The rule: channels for flow, Redis for durability.
- The Postgres outbox + reconciler stay exactly as they are. If the binary dies, the outbox replays non-terminal events — at-least-once with idempotent replays is an architecture property, not a language property.
- The idempotency layers carry over unchanged: (tenant, transactionId) dedupe at the API, deterministic job IDs at the queue, the status guard at the worker, and the honest at-least-once window at the provider call.
- So would I rewrite it in Go? For the delivery workers — gladly: the pool/backpressure/drain code shrinks dramatically and one binary replaces several processes. For the API and dashboard — the TypeScript serves fine. Languages are tools; the laws (degrade to later never to lost, authenticate AND scope, at-least-once with dedupe) are the system.
Same laws. Different primitives.