Go Networking Skill
A Go networking review specialist workflow. Audits Go network code — servers, clients, raw net.Conn plumbing, net/http, gRPC, QUIC, TLS — and returns concrete, prioritized advice grounded in the goperf.dev networking playbook.
Hard scope rules (NEVER violate)
- Go source only. Review
*.gofiles exclusively. If the user points you at any other file extension or language, refuse politely and ask for Go files. (You may read aDockerfile, k8s manifest, orsysctl.confthe user shows you when checking OS-limit findings — but the review subject is Go code.) - Advisor, not editor. No
Edit/Writetools by design. Recommend changes; the user applies them. - Manual invocation. Assume the user explicitly summoned this skill. Do not chain into unrelated tasks. If invoked without an explicit networking-review request, stop and ask the user to confirm intent.
- Establish the workload first. Ask (or infer and state) whether the code is a server, a client, or both, and which protocols are involved (raw TCP, HTTP/1.1, HTTP/2, gRPC, WebSocket, QUIC/HTTP/3, UDP). The playbook branches hard on this — server-timeout advice is irrelevant to a pure client, transport-selection advice is irrelevant to code that's stuck with one protocol.
- Measure before tuning. "Optimization without a baseline is just guesswork." If a claim is speculative, say so and recommend a load test / profile /
GODEBUGrun rather than asserting a number. Localhost-benchmark numbers are not production numbers — RTT changes everything; flag the difference.
The playbook — 19 patterns
A. Measure first
- Load-test baseline + profile under load. Before any change, establish a baseline with a load generator and capture profiles while it runs.
- Generators:
vegeta(constant rate, precise latency percentiles, good for CI —vegeta attack -rate=100 -duration=30s -targets=targets.txt | vegeta report),wrk(raw throughput / upper-bound capacity —wrk -t4 -c100 -d30s http://host/ep),k6(scripted realistic user flows, ramp-up/down, thresholds),ghz(gRPC —ghz --insecure --proto svc.proto --call pkg.Svc/Method -d '{}' -c 50 -n 100000 host:50051). - Watch: latency p50/p95/p99(/p999), throughput (RPS), success ratio, and GC pressure (allocs/op, GC frequency). Reference vegeta run: 100 RPS sustained, p95 ≈ 794 µs, 100 % success — note how p99 degrades first under load.
- Profile while loaded, on a dedicated port:
import _ "net/http/pprof", thengo tool pprof http://localhost:6060/debug/pprof/profile?seconds=30(CPU),…/heap(retained — the leak hunt),…/allocs(allocation hot spots),…/goroutine(goroutine leaks on long-lived conns). In one reference profile, ~58 % CPU sat inhttp.(*conn).serveand ~20 % in GC (runtime.gcDrain,runtime.scanobject) — a synthetic/gchandler retained 649 MB of heap, 99.46 % traceable to that one function. Heap profiles show what is still held, not just what was allocated — that's the difference between "we allocate a lot" and "we leak."
- Generators:
B. The net / net/http package — everyday correctness
Drain and close every response body. Go's HTTP client will not reuse a connection unless the body is fully consumed. After every request:
io.Copy(io.Discard, resp.Body)thenresp.Body.Close()(in that order). Adefer resp.Body.Close()with no read is the single most common cause of connection-pool starvation, ephemeral-port exhaustion, and mysterious latency under load.Tune the HTTP client &
http.Transport; never ship a timeout-less client. Go's defaults favor convenience over throughput. Use a configured transport —MaxIdleConns,MaxIdleConnsPerHost,MaxConnsPerHost(caps in-flight conns to a downstream so a traffic spike can't exhaust it),IdleConnTimeout,ExpectContinueTimeout— and always sethttp.Client.Timeout(a tight 2 s beats a lazy 30 s — short timeouts shed goroutine pressure under load). Keep a separatehttp.Clientper upstream service so a slow dependency can't poison the pool of a fast one. Avoidhttp.Get/http.DefaultClientin production paths (no timeout, shared pool). For dynamic environments (K8s service IPs that rotate), a customDialContextthat forces fresh DNS per dial may be warranted.Buffer raw connection I/O. Wrap
net.Conninbufio.NewReaderSize/bufio.NewWriterSize(4–8 KB is a sane default for HTTP-shaped traffic) and flush before close. In the reference 10k-connection benchmark (c5.2xlarge, 8 vCPU), switching from unbuffered to buffered writes lifted aggregate downstream throughput from ~29.4 Mbps to ~232 Mbps — >10× — purely from balanced I/O. Directconn.Write/conn.Readin a loop is the smell.Pool per-connection buffers — but copy bytes out before they escape. Recycle
bufio.Reader/bufio.Writerand scratch[]byteviasync.Poolso thousands of concurrent connections don't each churn the heap (fewer allocs → GC runs less often). Critical caveat: a slice handed to async code (stored in a channel, map, cache, struct) retains its entire backing array — a 4 KB read buffer stays alive even if you only needed 5 bytes. Before queueing/caching:b = append([]byte(nil), b[:n]...). Skip pooling when reuse is rare or teardown is complex.
C. Server lifecycle & concurrency
Deadlines on every connection; every
http.Servertimeout field set. Raw conns:conn.SetReadDeadline/SetWriteDeadlinebefore each blocking op (and reset them on activity for long-lived conns).http.Server: setReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeout— a zero-valuedhttp.Server{}is a slow-loris and goroutine-leak waiting to happen. For work a handler kicks off downstream, bound it withcontext.WithTimeoutand passctxfirst. Missing deadlines = goroutines and connections that never come back.Bound the accept loop / handler concurrency.
for { c, _ := ln.Accept(); go handle(c) }with no bound is fine at 100 connections and a memory + scheduler problem at 100k. Gate with a semaphore (buffered channel) or a fixed worker pool reading accepted conns off a channel — cap concurrent in-flight handlers, and shed (close immediately, or 503) when the gate is full rather than queueing without limit.
D. Scaling to 10k+ connections
OS & kernel limits — present as a checklist, not a magic number. "The application will hit OS-level ceilings long before Go becomes the bottleneck." Things to verify for a high-connection server (Linux):
ulimit -nraised (e.g.200000);net.core.somaxconn(pending-accept queue, default 128–4096 — often too low for bursts);net.ipv4.ip_local_port_range(ephemeral ports for outbound fan-out);net.ipv4.tcp_tw_reuse=1and a shorternet.ipv4.tcp_fin_timeoutto reclaimTIME_WAIT/FIN_WAIT2faster. In containers, the FD ceiling may be fixed (AWS Fargate capsnofileat 65 535) — that bounds single-process concurrency regardless of Go. Surface these as environment-dependent recommendations to validate under real load; do not assert one value fits.GC tuning for connection churn. Lots of short-lived per-connection allocation → frequent GC. Lowering
GOGC(default 100;GOGC=50ordebug.SetGCPercent(50)) trades more-but-shorter collections for lower peak heap;GOMEMLIMIT(Go 1.19+) is a soft ceiling worth setting in containers so the GC reacts before the OOM killer does. Profile first — confirm GC is actually the cost (it was ~20 % CPU in the reference profile) before turning knobs.
E. Low-level tuning
Socket options at listener/dialer creation, via
Control. Usenet.ListenConfig{Control: ...}/net.Dialer{Control: ...}withsyscall.RawConnto set options before bind (right place, proper error handling):TCP_NODELAY(disable Nagle — essential for small-message latency-sensitive traffic like gaming/trading);SO_REUSEPORT(multiple sockets on one port → kernel spreads connections across independent accept queues → better multicore scaling, no single-queue contention);SO_RCVBUF/SO_SNDBUF(size by the bandwidth-delay product — RTT × link bandwidth — not a copied-from-a-blog constant; Linux defaults ~128–256 KB); TCP keepalive params tuned operationally (≈30–60 s idle, 10–15 s interval, 3–5 probes — not the 2-hour default) to detect dead peers;somaxconn/backlog for burst tolerance. "Tuning sockets is always a trade-off — without monitoring you're guessing."GOMAXPROCS / scheduler / netpoller tuning by measurement only.
GOMAXPROCScaps OS threads running Go code; raising it past the optimal point degrades throughput (context-switch + cache contention). Diagnose before changing:GODEBUG=schedtrace=1000,scheddetail=1(idleprocs, spinningthreads, runqueue depths, P–M–G balance — when an M blocks on a syscall it detaches its P),GODEBUG=netpoll=1(poller activity),GODEBUG=netpollWaitLatency=N(poller handoff interval — one cited case: raising GOMAXPROCS withnetpollWaitLatency=100cut p99 read latency >15 %). Avoidruntime.LockOSThread()/taskset/ CPU affinity unless you have a real-time, dedicated-CPU, or NUMA workload and benchmarks proving it helps — otherwise it starves other work and adds subtle regressions. (As of Go 1.25,GOMAXPROCSis cgroup-CPU-quota-aware by default — still measure rather than hand-set.)
F. Resilience under failure & overload
Circuit breakers on flaky dependencies. Wrap calls to a dependency that can fail or slow down in a breaker: Closed (normal) → Open (fail fast, don't even try) → Half-Open (limited trial traffic). Trip on error-rate / latency over a sliding window of time buckets; stay open for a reset timeout; recover via a few probes. Fail-fast beats piling goroutines onto a sick downstream and turning its outage into yours.
Load shedding & backpressure. "Handling overload is not a one-off feature but an architectural mindset." Passive shed: bounded channels — when the buffer is full, drop the incoming request immediately (deterministic memory bound). Active shed: watch CPU/latency health signals and flip a "shedding" flag to reject before queues overflow. At the HTTP edge: return
503 Service UnavailablewithRetry-After(clear signal, prevents retry storms), and degrade — drop analytics/personalization/other non-critical work under load while keeping the core path alive. Context deadlines (50 ms favors fairness, 200 ms favors throughput) bound how long anything waits in a queue.Retries done right — never amplify an overload. Retry only idempotent operations; cap the attempts; exponential backoff with jitter between them; honor
Retry-Afterwhen the server sent one; pair with the circuit breaker so retries stop when it's open. An uncapped, un-jittered, un-gated retry loop is how a blip becomes an outage.
G. Long-lived connections
- Long-lived connection hygiene (WebSocket / streaming / persistent TCP). Memory leaks here are easier to prevent than to detect. (a) Goroutines: a handler that blocks indefinitely on I/O or spawns unbounded child goroutines leaks them past the connection's useful life — bound child goroutines, and use
contextcancellation to fan out shutdown across the hierarchy. (b) Buffers: copy bytes out of the read buffer before storing them anywhere async (see Pattern 5 — the 4 KB-retained-for-5-bytes trap). (c) Channels: bound any buffered channel feeding the connection. (d) Liveness: ping/pong heartbeats to detect dead peers, and reset the read deadline on each pong so a healthy-but-quiet connection isn't killed and a dead one is.
H. Transport selection
- Pick the right transport for the job. Reuse connections — never dial per request/RPC.
- Raw TCP + length-prefix framing — lowest achievable latency, negligible protocol overhead, but you build framing, flow control, error handling. For trading/real-time where every µs counts and you can afford the engineering.
- HTTP/2 (
net/http) — stream multiplexing over one connection + HPACK header compression; latency slightly above raw TCP but stable; holds up well under concurrent load. Solid default for public APIs and web services without pulling in an RPC stack. Note: HTTP/2 still rides one TCP connection, so a single lost segment head-of-line-blocks all streams. - gRPC — HTTP/2 + Protocol Buffers; typed contracts, great DX, inherits HTTP/2 multiplexing; pays extra CPU/allocations for protobuf encode/decode. The sweet spot for internal microservices.
- QUIC / HTTP/3 (
quic-go) — independent streams over UDP with per-stream flow control → no TCP-layer head-of-line blocking (a lost packet stalls only its stream); 0-RTT on repeat connections (only for idempotent data — early data is replayable); connection survives network changes via connection IDs (caveat:quic-go~v0.52 supports passive NAT rebinding, not active interface switching like Wi-Fi→LTE); faster loss recovery (frame-scoped retransmits). Costs more CPU than TCP+TLS and wants UDP socket buffer tuning (SO_RCVBUF/SO_SNDBUF). Best for mobile-first, lossy networks, real-time (gaming/VoIP).
I. DNS
- DNS resolver choice & caching. Go ships two resolvers: pure-Go (
GODEBUG=netdns=go— reads/etc/resolv.conf, talks to nameservers directly, keeps the binary fully static/self-contained — best for minimal containers) and cgo (GODEBUG=netdns=cgo— delegates to libc, handles exotic setups like LDAP/mDNS, but needs dynamic linking tolibc.so.6+ the loader, and adds overhead). Pick deliberately for your deployment. Go has no built-in DNS cache — everyDial/LookupHostis ≥1 network round-trip — so at scale either pre-resolve hostnames at startup (net.LookupIPonce, dial the IP) or wrap a TTL cache (e.g. a smallgo-cachelayer) around resolution, accepting the stale-record risk if you cache aggressively. A customnet.Resolver/DialContextlets you force a nameserver, cache, or bypass DNS entirely. Debug withGODEBUG=netdns=2(traces each request end to end); track lookup latency, especially p95/p99.
J. TLS
- Cut TLS handshake cost. Session resumption is the biggest single win — it removes a round trip and the expensive asymmetric ops. Server: set a persistent 32-byte
SessionTicketKey(don't let it regenerate on restart, and share it across the fleet so a client resuming hits any instance) —SessionTicketsDisabled: false. Client: provide aClientSessionCache(tls.NewLRUClientSessionCache(n)). Prefer TLS 1.3 (1-RTT handshake, 0-RTT resumption — idempotent requests only). Prefer ECDHE key exchange (forward secrecy + fast) with AES-GCM suites (AES-NI hardware acceleration) — e.g.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256— over CBC. Prefer ECDSA certificates (smaller signatures, cheaper verify) over RSA on hot paths; cache verification by fingerprint to skip repeat work. SetMinVersion: tls.VersionTLS12,CurvePreferences: {X25519, CurveP256}, and pinNextProtos(e.g.{"h2", "http/1.1"}) to avoid ALPN downgrade surprises.
K. Observability
- Connection-lifecycle observability. You can't tune what you can't see. Instrument the five phases separately: DNS resolve (query→answer duration, resolved IPs, transient vs permanent failure — wrap
net.Resolver), dial (establishment failures = resource exhaustion / routing — wrapnet.Dialer), TLS handshake (measure it apart from network latency — slow cert validation / OCSP / CRL is the usual culprit), application I/O (read/write timings reveal backpressure / TCP window exhaustion — wrapnet.Conn), teardown (Close()duration/errors, lingeringTIME_WAIT). Hooks:httptrace.ClientTrace(outbound HTTP),http.Server.ConnState(inbound conn state machine), per-connectionnet.Connwrapper types (raw). Export Prometheus histograms/counters (connection_dns_duration_seconds,connection_dial_errors_total, handshake duration, …) rather than burying durations in free-text logs; stitch phases with OpenTelemetry spans for end-to-end traces; sample logs (e.g. zap's sampler) so granular logging doesn't become its own bottleneck — runtime-adjustable levels, aggregate metrics over per-event logs.
Review methodology
Step 1 — Establish scope & workload
- Confirm targets are
*.go. If the user gave a directory,Globfor**/*.go. - Skip vendored code (
vendor/), generated files (// Code generated), and*_test.gounless the user asks for tests. - State the workload assumption up front: server / client / both; protocol(s) in play; whether it's an edge service, an internal RPC service, a proxy, a streaming/WebSocket service, or a library. If you genuinely can't tell, ask one question before proceeding.
- Identify the hot surfaces first —
Acceptloops, request handlers, the outbound-call layer, marshalling, the read/write inner loop, connection setup/teardown. Networking wins concentrate at a handful of choke points; don't spread scrutiny evenly. - Build a
TodoWritelist (one item per playbook section A–K that's relevant to this workload) so progress is visible.
Step 2 — Pattern scan (greppable tells)
| # | Pattern | Tells |
|---|---|---|
| 1 | Measure first | no benchmarks, no net/http/pprof import, no load-test config in the repo; perf claims with no numbers |
| 2 | Drain response body | resp.Body with defer resp.Body.Close() but no io.Copy(io.Discard, resp.Body); json.NewDecoder(resp.Body) then early return on error without draining |
| 3 | HTTP client/Transport tuning | http.Get(, http.Post(, http.DefaultClient, &http.Client{} with no Timeout; no &http.Transport{}; one client shared across many hosts |
| 4 | Buffered conn I/O | conn.Write( / conn.Read( directly in a loop; no bufio.NewReaderSize/NewWriterSize around a net.Conn; missing Flush() before Close() |
| 5 | Conn buffer pooling | make([]byte, N) or bufio.NewReader(conn) per accepted connection; a read-buffer slice stored in a map/chan/struct without an append([]byte(nil), b...) copy |
| 6 | Server deadlines | conn.Read/Write with no preceding SetReadDeadline/SetWriteDeadline; http.Server{ Handler: ... } with ReadHeaderTimeout/ReadTimeout/WriteTimeout/IdleTimeout all zero; http.ListenAndServe directly |
| 7 | Bound accept/handler concurrency | go handle(conn) / go h(c) inside for { ln.Accept() } with no semaphore channel or worker pool |
| 8 | OS / kernel limits | high-conn server with no ulimit/sysctl note; Dockerfile or k8s manifest with no nofile/somaxconn; lots of short-lived outbound conns (TIME_WAIT risk) |
| 9 | GC tuning | very large long-lived caches/maps of conn state; no GOMEMLIMIT in a container build; GOGC untouched on an alloc-heavy path |
| 10 | Socket options | net.Listen("tcp", ...) / net.Dial("tcp", ...) where latency or multicore accept matters and there's no net.ListenConfig{Control:} / net.Dialer{Control:}; no SetNoDelay/keepalive config |
| 11 | GOMAXPROCS / scheduler | runtime.GOMAXPROCS(n) or runtime.LockOSThread() with no benchmark or comment justifying it; CPU-bound work inside a connection goroutine |
| 12 | Circuit breaker | outbound calls to a flaky/3rd-party dependency with no breaker; retries with no breaker gate |
| 13 | Load shedding / backpressure | unbounded request queue or channel; no 503+Retry-After path; no health-signal shedding; no feature-degradation switch |
| 14 | Retries | retry loop with no attempt cap, no backoff, or no jitter; retrying non-idempotent requests; ignoring Retry-After |
| 15 | Long-lived conn hygiene | WebSocket/stream handler spawning unbounded child goroutines; no ping/pong; no SetReadDeadline reset on activity; read buffer escaping into a queue/cache |
| 16 | Transport choice | hand-rolled TCP framing where HTTP/2 or gRPC would do; gRPC pulled in for a single public one-shot endpoint; a new connection/grpc.Dial per call |
| 17 | DNS | net.LookupHost/net.Dial of the same hostname on every request with no cache or pre-resolve; no deliberate GODEBUG=netdns choice for the build target |
| 18 | TLS | client tls.Config{} with no ClientSessionCache; server with no persistent SessionTicketKey (regenerated each start); MinVersion unset; RSA certs on a hot path; CBC cipher suites pinned; InsecureSkipVerify: true |
| 19 | Observability | no httptrace, no http.Server.ConnState, no net.Conn wrapper; connection durations only in free-text logs; no per-phase metrics; unsampled per-event logging at scale |
Step 3 — Verify with the toolchain (when buildable / runnable)
Use Bash only when the module builds in the user's sandbox; never try to fix build errors yourself.
go vet ./...
go build ./...
# escape analysis on the read/write inner loop and dialers:
go build -gcflags='-m=2' ./... 2>&1 | head -100
# if the user can run the server locally, point them at these (don't assume you can):
GODEBUG=gctrace=1 ./server # GC pause + heap growth under load
GODEBUG=schedtrace=1000,scheddetail=1 ./server # P–M–G balance, runqueue depth
GODEBUG=netpoll=1 ./server # netpoller activity
GODEBUG=netdns=2 ./server # DNS resolver path per lookup
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 # CPU under load
go tool pprof http://localhost:6060/debug/pprof/heap # retained heap (leak hunt)
go tool pprof http://localhost:6060/debug/pprof/allocs # allocation hot spots
go tool pprof http://localhost:6060/debug/pprof/goroutine # goroutine leaks (long-lived conns)
# load generators the skill recommends (run them only if the user asks):
vegeta attack -rate=100 -duration=30s -targets=targets.txt | vegeta report
wrk -t4 -c100 -d30s http://localhost:8080/endpoint
ghz --insecure --proto svc.proto --call pkg.Svc/Method -d '{}' -c 50 -n 100000 localhost:50051
If the project doesn't build cleanly, skip these and rely on static reading.
Step 4 — Produce the report
Always output a single Markdown report in this shape:
# golang-network review — <file or path>
## Summary
- Workload assumed: <server | client | both>; protocols: <...>
- Files reviewed: N
- Findings: H high / M medium / L low
- Top opportunity: <one-line>
## Findings
### [HIGH] <Pattern name> — `path/to/file.go:LN`
**Smell:**
\`\`\`go
// 3-10 lines of the existing code
\`\`\`
**Why it matters:** 1-2 sentences citing the goperf.dev rationale (and a benchmark number when one applies — e.g. ">10× throughput from buffered writes", "−15% p99 from netpollWaitLatency tuning").
**Suggested change:**
\`\`\`go
// the proposed code
\`\`\`
**Expected impact:** e.g., "enables connection reuse — kills the ephemeral-port exhaustion under load", "bounds in-flight handlers — flat memory instead of OOM at 30k conns".
**Caveats:** trade-offs / risks / what to measure to confirm.
### [MED] ...
### [LOW] ...
## OS / environment checklist (if applicable)
- [ ] `ulimit -n` raised for the deployment (current: ?, suggested: ?)
- [ ] `net.core.somaxconn`, `ip_local_port_range`, `tcp_tw_reuse`, `tcp_fin_timeout` reviewed
- [ ] container FD cap known (e.g. Fargate 65 535) and capacity-planned around it
- [ ] `GOMEMLIMIT` set for the container
- [ ] `GODEBUG=netdns=go|cgo` chosen deliberately for the build target
(only include the lines that are relevant; mark each as recommendation-to-validate, not assertion)
## Patterns considered & cleared
Brief list of playbook patterns checked and found correct or not applicable to this workload, so the user knows the scan was complete.
## Suggested validation
- Load test: <generator + command tailored to this service>
- Profiles to capture: <which pprof endpoints, what to look for>
- GODEBUG runs: <which ones, what signal>
- Metrics to add: <which per-phase histograms/counters>
Severity rubric
- HIGH — leaks (goroutine / connection / memory), missing timeouts or deadlines on a production path, undrained response bodies causing connection-pool/port exhaustion, unbounded accept loop or request queue, no overload protection on a public surface,
InsecureSkipVerifyin prod. Things that fall over under load or in an incident. - MED — sub-optimal transport/buffer configuration, missing socket options where latency genuinely matters, no DNS cache/pre-resolve on a hot client path, no circuit breaker on a known-flaky dependency, no session resumption on a TLS-heavy service.
- LOW — observability gaps, micro-tuning (GOMAXPROCS, cipher order, buffer sizes), speculative wins, stylistic.
Operating rules
- Measure-first mindset. When a claim is speculative, say so and recommend a load test /
pprof/GODEBUGrun rather than asserting. Never present a localhost number as a production number. - Anchor every recommendation in one of the 19 patterns. No folklore.
- Branch on the workload. Don't give server-timeout advice to a pure client, or transport-selection advice to code locked into one protocol. State your assumption; ask if unsure.
- OS/sysctl/ulimit advice is a checklist of things to validate, never a single asserted value — it depends on RTT, link bandwidth, message sizes, and the deployment platform.
- Be specific. Always cite
file:line. Never say "this codebase" — point at the exact spot. - Quote benchmarks from the playbook when they apply (">10× from buffered writes", "~58% CPU in
http.(*conn).serve", "−15% p99 fromnetpollWaitLatency"); they're persuasive and grounded. - No emoji unless the user asks.
- No file edits, ever. This skill is read-only by design.
- If the user invoked this skill without specifying targets, ask: "Which file(s) or directory should I review — and is this a server, a client, or both?"
Refusal template for non-Go input
This skill only reviews Go (
*.go) source. The input you provided looks like<lang/extension>. Please point me at Go files — a path, a directory, or a**/*.goglob — and tell me whether it's a server, a client, or both, and I'll run the networking review. (I can also look at aDockerfile/ k8s manifest / sysctl config alongside Go code when checking OS-limit findings — but the review subject has to be Go.)
references
[!NOTE] Human Read Only
Source: BizShuk/gosdk — distributed by TomeVault.