Day 8 of 100·Project Deep-Dive Day·552 words

Your two runtimes compared — Go (zoth) vs Node (traverse)

Concrete, code-level differences between the two backends you own — from startup to shutdown.

Know your own system

Interviewers who see Go and TypeScript/Node on a resume will ask you to compare them. You own both services — so answer from your code, not from blog posts.

Startup and wiring

Zoth cmd/api/main.go boots in strict order: load config (config.Load() — env → .env → defaults, with Validate() that fatally rejects a JWT secret under 32 bytes) → connect Postgres → run embedded goose migrations under pg_advisory_lock(72183921) (two replicas migrating concurrently can't interleave) → optionally connect RabbitMQ (failure is tolerated with a warning — the outbox keeps the backlog) → construct repositories → services → router with middleware → start expiry loop + outbox relay goroutines → srv.ListenAndServe.

Traverse src/index.ts: dotenv → express → middleware stack → route mounts → async boot (prisma.$connect(), initializeQueues() with a 30 s Redis readiness wait, initializeCronJobs()) → listen on 0.0.0.0 in production. Container start (Dockerfile → start.sh) is even stricter: embedded redis-server starts, then npx prisma migrate deploy — and the container refuses to boot on a failed/stale migration (exit 1). Different runtimes, same principle: fail fast at boot, degrade gracefully at runtime.

Graceful shutdown

  • Zoth: signal.Notify(SIGINT, SIGTERM) → srv.Shutdown(ctx) with 10 s to drain in-flight requests → OTel flush with 5 s → close publisher/consumer/DB. The K8s termination path depends on this (Day 44: probes + terminationGracePeriod).
  • Traverse: SIGTERM → closeAllQueues() (BullMQ workers finish current jobs) → closeRedisConnections() → prisma.$disconnect() → shutDownOtel() → exit 0. Also: deploy-remote.sh health-checks /api/health/db 20 times × 5 s after docker-compose up -d --build.

Type systems and data access

  • Go + SQLC: you write raw SQL, SQLC generates typed Go structs — SQL is checked at compile time against the schema. Zero reflection, zero ORM magic. emit_pointers_for_null_types, uuid overrides.
  • TS + Prisma: schema-first, generated client, P2002 error codes to catch in mapPrismaError. The tradeoff: Prisma gives you migrations + types from one source; SQLC gives you the exact SQL you'll run. Your honest line: "I picked the ecosystem default in each language and would reach for the other one (pgx/Drizzle) if Prisma's query layer became opaque."

Error philosophy

  • Zoth: sentinel AppErrors (pkg/errors — ErrNotFound/ErrForbidden/ErrConflict…) rendered into a nested {"error":{"code","message"}} envelope by middleware. AppError.Is compares by code, so wrapped messages still match sentinels.
  • Traverse: AppError(message, statusCode, {code, field}) + mapPrismaError + SAFE_ERRORS allow-list, so raw Prisma text never leaks (full treatment Day 72).

Concurrency primitives used for real

NeedZoth (Go)Traverse (Node)
background looptime.Ticker goroutine + contextnode-cron + BullMQ repeatable job
fan-outgoroutines per probe (health checks run concurrently)Promise.all/allSettled
mutual exclusionsync.Mutex (publisher, registry)single-threaded loop + Redis ops
atomic claimUPDATE ... WHERE status=$expectedFOR UPDATE SKIP LOCKED

Performance reality check

Go compiles to a static ~15 MB binary, boots in ms, no GC tuning needed at this scale. Node needs the runtime + node_modules but wins on iteration speed and the huge extension/iOS-adjacent ecosystem. Your summary line for interviews: "Go for the identity-control-plane where I wanted explicit SQL, tiny footprint, and static deployment to a 1-node cluster; Node for traverse because the whole product surface (extension, web, tooling) is TypeScript — one language across the stack beat runtime micro-optimization."

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

Why is Go the right choice for zoth specifically?

Static binary into a distroless-ish alpine image (two-stage Dockerfile, CGO_ENABLED=0) keeps the image tiny for ACR push/pull on a cheap node; goroutines made the expiry loop, outbox relay, and consumer dispatch trivial; SQLC + Go's type system kept hand-written SQL the source of truth; and the 15/15s read/write timeouts plus signal-based graceful shutdown map directly onto Kubernetes lifecycle. None of these are impossible in Node — but each was cheaper in Go for a control plane.

02

Your traverse container runs migrations on every boot and exits if they fail. Why?

Because a stale schema serving traffic is worse than downtime: code compiled against columns that don't exist fails at runtime in unpredictable places. Failing the boot makes the deploy visibly broken (docker-compose health checks / deploy script fails) instead of silently wrong. The counterpart in zoth is migrations under pg_advisory_lock at API startup — same principle, different mechanism (zoth can't exit because K8s restarts it anyway; instead migration failure is fatal before ListenAndServe).

03

How do the two stacks handle a SIGTERM during in-flight work?

Zoth: http.Server.Shutdown drains requests up to 10 s, OTel flushes within 5 s, publisher/consumer/DB close in defer. Traverse: BullMQ workers close (current job finishes), Redis connections close, Prisma disconnects, OTel shuts down. In both, anything that exceeds the deadline is picked up after restart by the durability layer — outbox relay in zoth, at-least-once redelivery + idempotency in traverse. Shutdown correctness is really a recovery story.