Date: April 2026 Scope: Full codebase analysis across all subsystems Findings: 53 performance issues, 4 correctness bugs, 7 critical bottlenecks
| Fix | Status | Verified |
|---|---|---|
| CSV writer missing mutex | DONE | go build, go test ./io/, go vet |
Elastic sendBulk() race in Close() |
DONE | go build, go vet |
| Round-robin counter race (atomic) | DONE | go build, go vet |
sync.Pool for PacketContext |
DONE | go build, go vet |
DataFragments reader() → io.MultiReader |
DONE | go build |
| Regex compiled in loop (mail) | DONE | go build |
| CIDR blocks pre-parsed at init (filter) | DONE | go build, go test ./internal/filter/ |
Filter regex cache (MatchesPattern) |
DONE | go build, go test ./internal/filter/ |
| JSON writer: remove outer lock (inner suffices) | DONE | go build, go test ./io/ |
| Elastic writer: exponential backoff (max 5 retries) | DONE | go build, go test ./io/ |
| PacketBufferSize 100→1000, Workers CPU-proportional | DONE | go build, go test ./collector/ |
| Cache layerTypeStr per layer iteration | DONE | go build |
| Bounded DNS cache (100K max, evict on overflow) | DONE | go build, go test ./resolvers/ |
| DNS timeout reduced 10s→2s | DONE | go build |
| Bounded GeoIP cache (100K max, RWMutex) | DONE | go build, go test ./resolvers/ |
| TCP flow .String() caching in decode() | DONE | go build |
| MAC OUI: normalize at insert, direct hex parse | DONE | go build, go test ./resolvers/ |
| Replace deprecated ioutil → io/os | DONE | go build, go test ./io/ |
| Cache reflect.TypeOf().String() in TCP decoder | DONE | go build |
| Replace deprecated ioutil in HTTP decoder | DONE | go build |
| GeoIP repr(): replace fmt.Sprintf with concat | DONE | go build |
| DNS: RWMutex for read path (RLock on cache hit) | DONE | go build, go test ./resolvers/ |
- Executive Summary
- Core Pipeline Performance
- Decoder Subsystem
- Resolvers & Enrichment
- Internal Utilities
- Protobuf Schema
- Prioritized Recommendations
- Verification Strategy
Netcap's architecture is well-designed for parallel packet processing, but several performance bottlenecks limit throughput under high-speed capture scenarios. The most critical issues fall into four categories:
- Lock contention — Coarse-grained mutexes serialize work that could run in parallel (protobuf writer, stream pool, JSON writer)
- Memory pressure — Missing object pooling, unbounded caches, and unnecessary data copies create GC overhead that scales linearly with throughput
- Hot-path allocations — Reflection, string formatting, and regex compilation in per-packet/per-stream code paths
- Correctness bugs — Race conditions and missing synchronization that can cause data corruption
| Rank | Issue | Location | Category |
|---|---|---|---|
| 1 | CSV writer missing mutex | io/csv_writer.go:38 |
Correctness |
| 2 | Elastic sendBulk() race condition |
io/elastic.go:220 |
Correctness |
| 3 | Round-robin counter race | collector/collector.go:609 |
Correctness |
| 4 | StreamPool coarse RWMutex | reassembly/stream_pool.go:33 |
Lock contention |
| 5 | Protobuf writer mutex per write | io/protobuf.go:117 |
Lock contention |
| 6 | No PacketContext pooling | collector/worker.go:77 |
Memory |
| 7 | Unbounded DNS cache | resolvers/dns.go:32 |
Memory leak |
| 8 | Reflection in SSH unmarshaling | decoder/stream/ssh/messages.go:374 |
Hot-path alloc |
| 9 | Double data copy in streams | decoder/core/data_fragments.go:49 |
Memory |
| 10 | 10-second blocking DNS timeout | resolvers/dns.go:91 |
Blocking I/O |
File: collector/worker.go:77
ctx := &types.PacketContext{}A new PacketContext is allocated for every single packet processed. At high throughput (100K+ packets/sec), this creates millions of short-lived allocations per second, putting extreme pressure on the garbage collector.
Fix: Implement sync.Pool for PacketContext reuse:
var packetContextPool = sync.Pool{
New: func() interface{} { return &types.PacketContext{} },
}
// In worker loop:
ctx := packetContextPool.Get().(*types.PacketContext)
*ctx = types.PacketContext{} // zero-reset
defer packetContextPool.Put(ctx)File: collector/collector.go:619-629
c.workers[idx] <- p // Can block if worker buffer fullWhen worker buffers are full, the packet reading goroutine blocks, causing packet drops at the OS level. The timeout variant (handlePacketTimeout, lines 633-662) uses a 3-second timeout — far too long for real-time capture.
File: collector/config.go:40
PacketBufferSize: 100 // Default buffer per workerA buffer of 100 packets is insufficient for burst traffic on modern networks.
Fix:
- Increase default
PacketBufferSizeto at least 1000 - Reduce timeout to 100ms with metrics on drops
- Consider adaptive buffer sizing based on fill ratio
File: collector/collector.go:608-610
c.next++
if c.next >= len(c.workers) {
c.next = 0
}The c.next counter is read and written by the packet dispatcher without synchronization. Multiple goroutines calling handlePacket concurrently will race on this value, causing uneven worker distribution.
Fix: Use atomic.AddInt64 or distribute packets from a single goroutine.
File: collector/config.go:40
Workers: 10001000 goroutines by default is excessive for most systems. Each worker consumes stack memory and adds scheduling overhead. The optimal count depends on CPU core count and workload.
Fix: Default to runtime.NumCPU() * 4 or similar heuristic.
File: collector/worker.go:69, 136, 173
t := time.Now() // Called 2-3 times per packettime.Now() is a system call with measurable overhead (50-100ns). Called millions of times per second, this adds 3-5% CPU overhead.
Fix: Call once per worker iteration, reuse the value.
File: collector/worker.go:139, 176
gopacketDecoderTime.WithLabelValues(layer.LayerType().String()).Set(...)
customDecoderTime.WithLabelValues(customDec.GetName()).Set(...).String() allocates a new string on every call. When metrics are enabled, this runs per-packet.
Fix: Cache label strings in a map keyed by layer type at init time.
File: reassembly/stream_pool.go:30-39
type StreamPool struct {
mu sync.RWMutex // Single lock for ALL connections
conns []*connection
users int
// ...
}All TCP stream lookups serialize through a single RWMutex. The getConnection() method (line 181-209) holds this lock while calling p.factory.New(), which may be expensive.
Fix: Shard the connection map by flow hash (e.g., 64 shards), each with its own lock. This allows parallel stream processing for independent flows.
File: reassembly/stream_pool.go:41-51
func (p *StreamPool) grow() {
// Doubles allocation exponentially with no upper limit
}The page caches grow to handle peak workload and never shrink (noted in TODO at lines 107-110). A traffic spike causes permanently elevated memory usage.
Fix: Implement periodic shrinking or a high-water mark with gradual release.
File: reassembly/connection.go:13
The per-connection sync.Mutex is held during the entire AssembleWithContext() operation (lines 208-296), which includes all packet processing for that stream direction.
Fix: Narrow the critical section to only protect shared state updates.
File: io/protobuf.go:117-141
func (w *protoWriter) WriteProto(msg proto.Message) error {
w.mu.Lock()
defer w.mu.Unlock()
// ... proto.Size(msg) called here too
}Every protobuf message write acquires a mutex, and proto.Size(msg) is called inside the lock for performance tracking — duplicating serialization work.
Fix:
- Buffer writes in a lock-free ring buffer, flush in batches
- Move
proto.Size()outside the lock or compute size during serialization - Consider per-decoder writers to eliminate cross-decoder contention
File: io/csv_writer.go:38-46
The csvWriter struct has no sync.Mutex field, unlike protobuf and JSON writers. Concurrent writes from multiple worker goroutines will corrupt CSV output.
Fix: Add mutex protection matching the pattern in other writers.
File: io/elastic.go:220
Close() calls sendBulk() without holding the mutex, while Write() calls it with the mutex held. Concurrent shutdown during active writing causes a data race.
Fix: Acquire the mutex in Close() before calling sendBulk().
File: io/elastic.go:459-464
if err != nil {
dur := 500 * time.Millisecond
fmt.Println("failure indexing batch:", err)
time.Sleep(dur)
continue // Infinite retry
}Failed bulk indexing retries forever with a fixed 500ms delay. If Elasticsearch is down, this blocks the mutex indefinitely, effectively deadlocking the writer.
Fix: Implement exponential backoff with a maximum retry count. After exhausting retries, log the error and drop the batch.
File: io/json_writer.go:115-135
The mutex is held while JSON marshaling occurs. JSON encoding is CPU-intensive and holds the lock far longer than necessary.
Fix: Marshal to a buffer outside the lock, then acquire the lock only for the write.
File: io/elastic.go:414-447
w.queue = w.queue[w.processed:] // Truncates, leaving dangling refsSlice truncation doesn't release the underlying array, causing memory retention. Combined with make([]proto.Message, w.wc.BulkSize) reallocations, this creates unnecessary GC pressure.
Fix: Use a ring buffer or copy remaining items to a fresh slice.
File: io/chan_writer.go:181-184
func (w *chanProtoWriter) Write(p []byte) (int, error) {
w.ch <- p // Blocks if receiver is slow
return len(p), nil
}Unbuffered channel send blocks indefinitely if the receiver is slow or dead, causing goroutine deadlocks.
Fix: Use a buffered channel with a select/timeout fallback.
File: io/protobuf.go:127-136
start := time.Now()
err := w.pWriter.putProto(msg)
duration := time.Since(start)
size := proto.Size(msg) // Duplicates serialization analysisWhen metrics are enabled, time.Now() and proto.Size() add ~15-20% overhead per write.
Fix: Estimate size from the serialized output length rather than calling proto.Size() separately.
File: decoder/core/data_fragments.go:39-51
func (d DataFragments) bytes() []byte {
var b bytes.Buffer
for _, dt := range d {
b.Write(dt.Raw()) // Copy 1
}
return b.Bytes() // Copy 2
}
func (d DataFragments) reader() io.Reader {
return bytes.NewReader(d.bytes()) // Wraps copy
}reader() calls bytes(), which copies all fragment data into a buffer, then wraps it in a reader. For large transfers (file downloads, etc.), this duplicates megabytes of data unnecessarily.
Fix: Implement io.MultiReader over fragments to stream without copying:
func (d DataFragments) reader() io.Reader {
readers := make([]io.Reader, len(d))
for i, dt := range d {
readers[i] = bytes.NewReader(dt.Raw())
}
return io.MultiReader(readers...)
}File: decoder/stream/ssh/messages.go:374-520
The SSH decoder uses reflect.ValueOf(), reflect.Type(), and field iteration to marshal/unmarshal every SSH message. This is called for every SSH packet.
v := reflect.ValueOf(msg)
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
// ... reflection-based access
}Fix: Replace with code-generated or hand-written marshal/unmarshal methods. This alone can yield 10-30x speedup for SSH decoding.
Files: decoder/stream/tcp/tcp_connection.go:501,507 and decoder/stream/udp/udp_stream.go:278
reflect.TypeOf(t.decoder).String() // Called per-stream for metricsFix: Cache the type string at decoder initialization time.
File: decoder/stream/http/http_reader.go:265, 272
flowExists := slices.Contains(s.Flows, ident) // O(n)
cidExists := slices.Contains(s.CommunityIDs, communityID) // O(n)With thousands of flows per software instance, this degrades to O(n) per lookup, making overall behavior O(n^2).
Fix: Use map[string]struct{} for O(1) lookups, convert to slice only for serialization.
File: decoder/stream/service/service_probe.go:629, 718, 830, 906-907, 1018, 1025
res = append(res, b) // In loops without capacity hintRepeated appends without pre-allocated capacity cause O(n) reallocations as slices grow.
Fix: Pre-allocate with make([]T, 0, estimatedCap) where the upper bound is known.
File: decoder/stream/http/http_reader.go:219, 226, 376
pass = strings.Join(arr, "; ") // Allocation per request
credentials.WriteCredentials(&types.Credentials{
User: strings.Join(values, "; "), // Another allocation
})Multiple string allocations per HTTP request/response in the highest-volume protocol decoder.
Fix: Use strings.Builder with pre-allocated capacity, or avoid joins where the result is immediately serialized.
File: decoder/stream/mail/mail_security.go:206
if matches := regexp.MustCompile(`d=([^\s;]+)`).FindStringSubmatch(dkim); len(matches) > 1 {Regex is compiled on every DKIM parsing call instead of once at package init.
Fix: Move to package-level var:
var dkimDomainRegex = regexp.MustCompile(`d=([^\s;]+)`)File: decoder/stream/tcp/tcp_connection.go:216-224
dataCpy := make([]byte, len(data))
l := copy(dataCpy, data)Every reassembled TCP segment is copied before being sent on a channel, then decoders (HTTP, SSH, etc.) may copy it again.
Fix: Use reference counting or ensure the reassembly layer provides owned buffers that don't need copying.
File: decoder/stream/tcp/tcp_connection.go:361-364, 418-421
ClientIP: t.client.Network().Src().String(), // Called here
ServerIP: t.client.Network().Dst().String(),
// ... and again later for community ID calculationFix: Compute once, store in local variables.
File: decoder/stream/tcp/tcp_connection.go:442-493
When port-based lookup fails, all registered decoders are iterated with CanDecodeStream() checks. Some checks (e.g., HTTP) call bytes.Contains up to 9 times.
Fix: Order decoders by frequency (HTTP first), add early-exit heuristics, or use a trie on magic bytes.
File: decoder/stream/http/http_reader.go:297, 310
body, err := ioutil.ReadAll(res.Body) // Deprecated
res.Body = ioutil.NopCloser(bytes.NewBuffer(body)) // DeprecatedFix: Replace with io.ReadAll and io.NopCloser (available since Go 1.16).
File: resolvers/dns.go:91-145
ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)Failed DNS lookups block the calling goroutine for up to 10 seconds. In the worst case, many unresolvable IPs can exhaust the worker pool.
Fix:
- Reduce default timeout to 1-2 seconds
- Implement async DNS with result channels
- Add circuit breaker: after N consecutive failures, skip resolution for a cooldown period
File: resolvers/dns.go:32-38
var dnsNamesDB = make(map[string]string) // Grows foreverThe DNS cache map grows without any eviction policy. Long-running captures will consume unbounded memory.
Fix: Use a bounded LRU cache (e.g., hashicorp/golang-lru) with a configurable max size (default 100K entries).
File: resolvers/dns.go:115-127
The implementation acquires the lock to check the cache, releases it, performs DNS resolution, then acquires the lock again to store the result. This 2x lock acquisition pattern doubles contention on cache misses.
Fix: Use sync.Map or a sharded map to reduce contention, or hold the lock and use a singleflight.Group to deduplicate concurrent lookups for the same IP.
File: resolvers/dns.go:82-86
// Iterates through 17 CIDR blocks on every call
for _, cidr := range privateCIDRs {
if cidr.Contains(ip) { return true }
}Fix: Use a radix tree (e.g., netaddr package) or pre-computed bitmap for O(1) private IP checks.
File: resolvers/geoip.go:37
sync.Map grows without eviction, similar to the DNS cache issue.
Fix: Bounded LRU cache.
File: resolvers/geoip.go:110-119
func (g *GeoLocation) repr() string {
return fmt.Sprintf("%s, %s, %s", g.City, g.Region, g.Country)
}Called on every GeoIP lookup, fmt.Sprintf allocates a new string each time.
Fix: Cache the string representation or use strings.Builder.
File: resolvers/mac.go:119
oui := strings.ToUpper(mac[:8]) // Allocates new stringCalled for every packet when MAC lookup is enabled.
Fix: Normalize to uppercase at insertion time, not lookup time.
File: resolvers/service.go:148-166
Port ranges like 1-65535 are fully expanded into individual map entries during initialization, creating potentially 65K+ entries.
Fix: Store range objects and use binary search for lookups.
File: dpi/dpi.go:293-321
Uses atomic.Bool + RWMutex for a double-checked locking pattern that could be simplified with sync.Once.
File: dpi/dpi.go:327, 347, 367
New DPI wrapper instances are created just to query protocol lists, with incomplete cleanup (missing DestroyWrapper() calls noted in comments).
Fix: Use pre-initialized module instances or sync.Once initialization.
File: internal/filter/filter.go:204-223
v := reflect.ValueOf(protoMsg)
for i := 0; i < v.NumField(); i++ {
env[field.Name] = convertFieldValue(fieldValue)
}Every filter evaluation creates a map via reflection over the protobuf message. For high-volume filtering, this is a major bottleneck.
Fix: Generate typed accessor functions at filter compilation time. Cache field metadata.
File: internal/filter/helpers.go:56-86
IsPrivateIP() in the filter package parses 16 CIDR strings on every call rather than at init time.
Fix: Parse once at package init, store as []*net.IPNet.
File: internal/filter/helpers.go:151-156
regexp.MatchString(pattern, input) // Compiles pattern each timeFix: Cache compiled regexes in a sync.Map keyed by pattern string.
File: internal/performance/tracker.go:34-85
A single sync.RWMutex protects 12+ map fields. Every resolver lookup acquires this lock.
Fix: Use per-metric atomic counters or sharded maps.
File: netcap.proto:234-256, 330-398
50+ fields use string type where enumerated values would be more efficient:
// Current (inefficient):
string ApplicationProto = 23; // "HTTP", "TLS", etc.
string DetectedProtocolName = 30; // "Facebook", "Google", etc.
string TransportProto = 20;
string NetworkProto = 21;
string LinkProto = 22;
// Recommended:
enum ApplicationProtocol { APP_UNKNOWN = 0; APP_HTTP = 1; APP_TLS = 2; ... }
ApplicationProtocol ApplicationProto = 23;Enums encode as varints (1-2 bytes) vs strings (4-20+ bytes), reducing wire size 3-4x for these fields.
File: netcap.proto:304-312
Field numbers jump non-sequentially (e.g., from 30 to 48-51). Protobuf field numbers 1-15 encode in 1 byte, while 16-2047 require 2 bytes. Frequently accessed fields should use numbers 1-15.
Fix: Reorder fields to place the most common ones in the 1-15 range. Note: this is a breaking change requiring data migration.
File: netcap.proto:281
repeated string Applications = 28; // String arrayFix: Use a packed enum array for known protocol values.
These are not just performance issues — they can cause data corruption or crashes.
| # | Issue | File | Fix |
|---|---|---|---|
| 1 | CSV writer has no mutex | io/csv_writer.go:38 |
Add sync.Mutex, match protobuf writer pattern |
| 2 | Elastic sendBulk() race in Close() |
io/elastic.go:220 |
Acquire mutex before sendBulk() in Close() |
| 3 | Round-robin counter race | collector/collector.go:609 |
Use atomic.AddInt64 |
| 4 | Chan writer blocking send | io/chan_writer.go:182 |
Add buffered channel + select/timeout |
| # | Issue | File | Fix |
|---|---|---|---|
| 1 | No PacketContext pooling | collector/worker.go:77 |
sync.Pool |
| 2 | Protobuf writer mutex per write | io/protobuf.go:117 |
Batch buffer, per-decoder writers |
| 3 | StreamPool coarse lock | reassembly/stream_pool.go:33 |
Shard by flow hash |
| 4 | SSH reflection unmarshaling | decoder/stream/ssh/messages.go:374 |
Code-gen marshaling |
| 5 | Double data copy in fragments | decoder/core/data_fragments.go:49 |
io.MultiReader |
| 6 | Unbounded DNS cache | resolvers/dns.go:32 |
Bounded LRU |
| 7 | 10-second DNS timeout | resolvers/dns.go:91 |
Async DNS, 1-2s timeout |
| 8 | Unbounded GeoIP cache | resolvers/geoip.go:37 |
Bounded LRU |
| 9 | Filter reflection per record | internal/filter/filter.go:204 |
Pre-compiled accessors |
| # | Issue | File | Fix |
|---|---|---|---|
| 1 | PacketBufferSize default 100 | collector/config.go:40 |
Increase to 1000+ |
| 2 | 1000 default workers | collector/config.go:40 |
CPU-proportional |
| 3 | time.Now() per packet | collector/worker.go:69 |
Cache per iteration |
| 4 | String metrics in hot path | collector/worker.go:139 |
Cache label strings |
| 5 | Slice appends without prealloc | decoder/stream/service/service_probe.go |
make([]T, 0, cap) |
| 6 | O(n) flow lookups | decoder/stream/http/http_reader.go:265 |
Use map |
| 7 | Repeated .String() calls | decoder/stream/tcp/tcp_connection.go:361 |
Cache in locals |
| 8 | CIDR parsed every call | internal/filter/helpers.go:56 |
Parse at init |
| 9 | Regex compiled every call | internal/filter/helpers.go:151 |
Cache compiled |
| 10 | MAC strings.ToUpper per packet | resolvers/mac.go:119 |
Normalize at insert |
| 11 | Regex in mail loop | decoder/stream/mail/mail_security.go:206 |
Package-level var |
| 12 | JSON writer lock scope | io/json_writer.go:115 |
Marshal outside lock |
| 13 | Elastic infinite retry | io/elastic.go:459 |
Exponential backoff + max retries |
| 14 | Elastic JSON per-record | io/elastic.go:421 |
Batch JSON encoding |
| 15 | Service port range expansion | resolvers/service.go:148 |
Store range objects |
| # | Issue | File | Fix |
|---|---|---|---|
| 1 | String fields → enums | netcap.proto |
Define enums for protocols |
| 2 | Field number ordering | netcap.proto |
Move frequent fields to 1-15 |
| 3 | Repeated string → packed enum | netcap.proto:281 |
Enum array |
| 4 | Batch write API | decoder/core/api.go |
Accumulate, flush in batches |
| 5 | Per-packet TCP data copy | decoder/stream/tcp/tcp_connection.go:218 |
Reference counting |
| 6 | Reassembly memory shrinking | reassembly/stream_pool.go:41 |
Periodic high-water shrink |
# Establish baseline benchmarks
make -f Makefile.test test-bench
# Save profiles for comparison
cp cpu.prof cpu.prof.baseline
cp mem.prof mem.prof.baseline# Run race detector to verify correctness fixes
make -f Makefile.test test-race
# Run unit tests
make -f Makefile.test test-unit
# Run benchmarks and compare
make -f Makefile.test test-bench
go tool pprof -diff_base cpu.prof.baseline cpu.prof# Integration tests with real PCAPs
make -f Makefile.test test-integration
# Memory profiling under load
go build -o net ./cmd/
./net capture -read large-test.pcap -memprofile mem.prof
go tool pprof mem.prof
# CPU profiling under load
./net capture -read large-test.pcap -cpuprofile cpu.prof
go tool pprof cpu.prof- Packets/second throughput on standardized PCAP
- Peak RSS memory usage
- GC pause times via
GODEBUG=gctrace=1 - Lock contention via
go tool pprof -contentionz - Allocation rate via
go tool pprof -alloc_space