polyhook

Full-Stack
2026-04-30T12:11:56Z
GoNode.jsTypeScriptSvelteSvelteKit

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.

  • Single SvelteKit 2 + Svelte 5 + Tailwind v4 frontend on Vercel (fra1) drives both runtimes — flip backend with one toggle, no rebuild
  • Two backends, one OpenAPI 3.1 contract, committed identically to both repos as the single source of truth
  • SSE broadcaster with per-bin subscriber sets and non-blocking publish, so slow clients never stall ingest
  • Cursor pagination using composite (received_at, id), stable across concurrent writes, unlike offset
  • Strong ETag + If-None-Match on the list endpoint for cheap polling
  • Per-IP token-bucket rate limiter (10 rps, burst 30) on ingest with X-RateLimit-* headers
  • Replay worker with exponential backoff 1s→2s→4s→8s→16s and dead-letter on exhaustion
  • Bins auto-expire after 24h via a sweeper: sync.RWMutex/goroutine in Go, setInterval in Node
  • Both services pure-language (no CGO, no native deps): modernc.org/sqlite + node:sqlite
  • Distroless multi-stage Docker images (~12MB Go, ~150MB Node) deployed to Railway with /healthz and /metrics
  • k6 bench harness identical for both services. Same script, BASE_URL is the only difference
  • Interactive in-browser playground on this page: create a bin, curl it from your terminal, watch the request stream in via SSE
  • Tab-toggled side-by-side code comparison covering the SSE broadcaster, rate limiter, replay worker, and pagination

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.

Go main.go
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:
		}
	}
}
TypeScript src/server.ts
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.

Metricpolyhook-gopolyhook-node
Latency p504 ms6 ms
Latency p9512 ms18 ms
Latency p9922 ms38 ms
Throughput200 rps200 rps
Error rate0 %0 %
RAM (steady)32 MB95 MB
Cold start50 ms320 ms
Image size12 MB150 MB
Total LOC (impl)944 lines691 lines