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)
fan-out / fan-in
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
the float valve
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:

  1. Every pool worker: job channel vs ctx.Done() (section 2's loop).
  2. The digest window: items arriving vs the 120s timer vs shutdown.
  3. 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() ──────►│                              │
                    └──────────────────────────────┘
the digest window as three doors
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:

  1. Per-send provider timeout — today a 10s HTTP timeout per provider call; in Go, context.WithTimeout wraps each send, and the HTTP client aborts mid-flight when it expires.
  2. 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.
  3. 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
the cancellation tree

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.