cd /news/developer-tools/how-i-debugged-and-fixed-memory-goro… · home topics developer-tools article
[ARTICLE · art-42205] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

How I Debugged and Fixed Memory & Goroutine Leaks in ProjectDiscovery Nuclei Engine 🚀

ProjectDiscovery Nuclei, an open-source vulnerability scanner, suffered from memory and goroutine leaks when embedded in long-running applications. A developer identified three primary leaks: an unbounded sync.Map in the HTTP-to-HTTPS port tracker, unreleased rate limiter goroutines, and persistent template caches. The fixes replaced the sync.Map with an expirable LRU cache, added proper cleanup in protocol state teardown, and introduced a Purge method for template caches, ensuring bounded memory and clean goroutine shutdown.

read3 min views1 publishedJun 28, 2026

If you work in cloud security or vulnerability scanning, chances are high that you rely on ProjectDiscovery Nuclei—the gold standard open-source vulnerability scanner powered by YAML templates.

While Nuclei performs exceptionally well as a standalone CLI tool, embedding it as an underlying SDK engine inside long-running microservices or continuous scanning workers introduces unique architectural challenges: memory bloat and goroutine leaks over extended execution loops.

Recently, I investigated and resolved these exact engine lifecycle leaks in ** Nuclei Issue #7503** and submitted

When embedding NucleiEngine

into a long-running application loop (where engines are instantiated and closed dynamically per scan target), I noticed that memory consumption climbed steadily over time, and orphaned goroutines remained active long after calling engine.Close()

.

Upon profiling the engine lifecycle in Go, I identified three primary memory leaks:

sync.Map

in HTTP-to-HTTPS Port Tracker:HTTPToHTTPSPortTracker

stored host port mapping states in an unbounded sync.Map

. Over thousands of target scans, this map grew infinitely without eviction.PerHostRateLimitPool

). When an engine execution finished, worker background routines were not cleanly shut down or purged.parsedTemplatesCache

and compiledTemplatesCache

) retained parsed representations in memory between engine instances without an explicit cache purging mechanism during engine teardown.Instead of holding unbounded host entries in a sync.Map

, I replaced the storage structure with an expirable LRU (Least Recently Used) cache configured with a strict capacity bound (4,096 entries) and a 24-hour TTL:

// Replacing unbounded sync.Map with bounded expirable LRU cache
type HTTPToHTTPSPortTracker struct {
    cache *expirable.LRU[string, struct{}]
}

func NewHTTPToHTTPSPortTracker() *HTTPToHTTPSPortTracker {
    return &HTTPToHTTPSPortTracker{
        cache: expirable.NewLRU[string, struct{}](4096, nil, 24*time.Hour),
    }
}

This guarantees that host mappings automatically expire and memory remains strictly bounded regardless of how many millions of URLs are scanned.

protocolstate.Close()

I updated the global protocol state tear-down procedure in pkg/protocols/common/protocolstate/state.go

to release rate limiter worker routines and purge trackers upon Close()

:

func Close(executionID string) {
    stateLock.Lock()
    defer stateLock.Unlock()

    if state, ok := globalStateMap[executionID]; ok {
        // Release per-host rate limiters and background goroutines
        if state.PerHostRateLimitPool != nil {
            state.PerHostRateLimitPool.Close()
        }
        // Purge HTTP to HTTPS tracker entries
        if state.HTTPToHTTPSPortTracker != nil {
            state.HTTPToHTTPSPortTracker.Purge()
        }
        delete(globalStateMap, executionID)
    }
}

Finally, I added a thread-safe Purge()

method to the template parser struct and invoked interface type assertions during NucleiEngine.Close()

:

// Safely purge compiled template caches on engine close
func (e *NucleiEngine) closeInternal() error {
    if e.parser != nil {
        e.parser.Purge()
    }
    if purger, ok := e.executerOpts.Parser.(interface{ Purge() }); ok {
        purger.Purge()
    }
    return nil
}

When designing solutions for large open-source codebases, evaluating architectural trade-offs is essential:

Options.HTTPToHTTPSCacheSize

) would be a clean future addition.interface{ Purge() }

) keeps the codebase decoupled and preserves backward compatibility for third-party SDK consumers using custom parsers without breaking their implementations.I validated these fixes across Nuclei unit test packages (httpclientpool

, protocolstate

, templates

, and lib

), verifying 100%

success with zero memory accumulation between consecutive engine shutdowns.

Fixes #7503 by implementing the required leak-prevention cleanup mechanisms outlined in #7502 for long-running embedded engines.

sync.Map

in HTTPToHTTPSPortTracker

(pkg/protocols/http/httpclientpool/http_to_https_tracker.go

) with a size-bounded expirable LRU cache (4096 entries max, 24h TTL) and added Purge()

.protocolstate.Close()

(pkg/protocols/common/protocolstate/state.go

) to release per-host rate-limit pool goroutines and purge the HTTP-to-HTTPS tracker on shutdown.NucleiEngine.Close()

/ closeInternal()

(lib/sdk.go

) and Parser

(pkg/templates/parser.go

) to purge parsed and compiled template caches on engine close.The embedded engine can leak memory and goroutines over time during long-running usage.

Implement the leak-prevention work described in #7502:

HTTPToHTTPS

tracker with an LRUWithout explicit cleanup and bounded caching, long-running embedders can accumulate memory usage and leave background goroutines running indefinitely.

HTTPToHTTPS

tracking / redirect bookkeepingHTTPToHTTPS

tracker is size-bounded and evicts old entries.PR title: fix leaks

sync.Map

in Long-Running Apps:sync.Map

is convenient, it lacks eviction policies. Use LRU caches with TTLs for dynamic lookup tables.Close()

/ Purge()

methods to release background channels and goroutines.if purger, ok := obj.(interface{ Purge() }); ok

enable clean resource cleanup without introducing rigid package dependencies.Written by @Thrylox. Connect with me on GitHub!

── more in #developer-tools 4 stories · sorted by recency
── more on @projectdiscovery 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-i-debugged-and-f…] indexed:0 read:3min 2026-06-28 ·