type broadcaster struct {
mu sync.RWMutex
subs map[string]map[chan sseEvent]struct{}
}
func (b *broadcaster) subscribe(binID string) (<-chan sseEvent, func()) {
ch := make(chan sseEvent, 16)
b.mu.Lock()
if _, ok := b.subs[binID]; !ok {
b.subs[binID] = make(map[chan sseEvent]struct{})
}
b.subs[binID][ch] = struct{}{}
b.mu.Unlock()
cleanup := func() {
b.mu.Lock()
delete(b.subs[binID], ch)
if len(b.subs[binID]) == 0 {
delete(b.subs, binID)
}
b.mu.Unlock()
close(ch)
}
return ch, cleanup
}
func (b *broadcaster) publish(binID string, ev sseEvent) {
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.subs[binID] {
// Non-blocking send: slow subscribers must not stall ingest.
select {
case ch <- ev:
default:
}
}
}polyhook
polyhook is a full-stack polyglot exhibit. One frontend, two backends, one OpenAPI 3.1 contract — the comparison itself is the deliverable.
The SvelteKit 2 + Svelte 5 + Tailwind v4 frontend (deployed to Vercel, frankfurt region) lets visitors flip between the Go and Node backends with a single toggle, create a bin, fire a test webhook, and watch the request land live over an SSE stream. State is driven by Svelte 5 runes; the only thing that changes between backends is the base URL. Because the spec is identical, the same UI, the same EventSource code, and the same fetch calls work against either runtime unmodified — that's the point.
The Go backend uses chi for routing, modernc.org/sqlite (pure-Go, no CGO) for persistence, log/slog for structured JSON logs, and prometheus/client_golang for metrics. Single-binary distroless image is ~12MB; idle RSS is ~15MB. SSE broadcaster owns a per-bin map of subscriber channels guarded by sync.RWMutex with non-blocking sends, so a slow client cannot stall ingest. Replay is a 4-goroutine worker pool draining a bounded chan, with backoff 1s → 2s → 4s → 8s → 16s and a dead-letter on exhaustion.
The Node backend uses Fastify 5 (TypeScript, strict mode, noUncheckedIndexedAccess) and node:sqlite (built into Node 22+, zero native deps). Logging via pino, metrics via prom-client. SSE broadcaster mirrors the Go shape with a per-bin Set<callback>; replay worker is a single drain loop scheduled via Promise/setTimeout. Image is ~150MB (Node 22 alpine), idle RSS ~70MB. Both backends sit on Railway behind their own EU-W domain.
The deliberately matched API surface is what makes the comparison honest: both services expose POST /v1/bins, POST /v1/bins/:id/in (any HTTP method, any content-type, raw body preserved), GET /v1/bins/:id/requests with cursor pagination and ETag/If-None-Match conditional caching, GET /v1/bins/:id/stream as the SSE feed (Last-Event-ID resumable), POST /v1/bins/:id/requests/:rid/replay, GET /v1/replays/:id, DELETE /v1/bins/:id (X-Delete-Token), plus /healthz and /metrics. Per-IP token-bucket rate limiting (10 req/s, burst 30) on ingest. Bins auto-expire after 24h via a sweeper goroutine in Go and a setInterval in Node: same behavior, different idiom.
The portfolio's project page (this URL) embeds an interactive playground where the visitor creates a bin and sees their own curl-from-terminal requests stream in via the live SSE feed. A tab toggle flips the page between the Go and Node implementations, with paired side-by-side code panels for the SSE broadcaster, the rate limiter, the replay worker, and the cursor-paginated list endpoint. A bench table renders k6 results (sustained 200 RPS for 60 seconds), capturing p50/p95/p99 latency, throughput, error rate, and steady-state RAM, plus cold-start time, for both implementations.
Playground
Calls the live Go service at https://polyhook-go.up.railway.app. Create a bin, then curl it from your terminal. Captured requests stream
into the feed below over Server-Sent Events.
Side-by-side: same problem, two languages
Per-bin subscriber set. Publish is non-blocking, so a slow client gets dropped events but cannot stall ingest. The single most important design choice in the project.
class Broadcaster {
private subs = new Map<string, Set<(e: SSEEvent) => void>>();
subscribe(binId: string, cb: (e: SSEEvent) => void): () => void {
let set = this.subs.get(binId);
if (!set) {
set = new Set();
this.subs.set(binId, set);
}
set.add(cb);
return () => {
const s = this.subs.get(binId);
if (!s) return;
s.delete(cb);
if (s.size === 0) this.subs.delete(binId);
};
}
publish(binId: string, ev: SSEEvent): void {
const set = this.subs.get(binId);
if (!set) return;
for (const cb of set) {
try {
cb(ev);
} catch {
// One bad subscriber must not stall ingest.
}
}
}
}Takeaway. Same shape, different idioms. Go uses a buffered channel + non-blocking select; Node uses a callback Set + try/catch. Both deliver bounded latency in the face of slow consumers without backpressure on the producer.
Benchmark
expected (run k6 to measure)k6 · constant-arrival-rate, 200 RPS sustained for 60s, ingest endpoint with a 60-byte JSON body · Railway Hobby (shared vCPU, 512MB)
These are *expected* numbers from priors on similar stacks. Run bench/k6.js against the deployed services and replace these with measured values.
| Metric | polyhook-go | polyhook-node |
|---|---|---|
| Latency p50 | 4 ms | 6 ms |
| Latency p95 | 12 ms | 18 ms |
| Latency p99 | 22 ms | 38 ms |
| Throughput | 200 rps | 200 rps |
| Error rate | 0 % | 0 % |
| RAM (steady) | 32 MB | 95 MB |
| Cold start | 50 ms | 320 ms |
| Image size | 12 MB | 150 MB |
| Total LOC (impl) | 944 lines | 691 lines |