The symptom is always the same shape: the app pool's private bytes climb steadily over hours or days until IIS recycles it, users on that worker process get dropped mid-session, and everyone blames "the server" instead of the DLL that has been quietly holding references it should have let go.
Confirm it is a leak, not just growth
Acumatica caches metadata, compiled BQL expressions, and site map data at the process level by design — memory climbing after a cold start and then plateauing is normal warm-up, not a leak. Watch Process\Private Bytes for the w3wp.exe instance over several hours under steady load. A leak keeps climbing after the plateau point a healthy instance would have reached; growth that flattens is fine.
while ($true) {
$p = Get-Process w3wp -Id (Get-WmiObject Win32_Process |
Where-Object { $_.CommandLine -match 'AcumaticaProd' }).ProcessId
"{0} PrivateMB={1}" -f (Get-Date), [math]::Round($p.PrivateMemorySize64/1MB)
Start-Sleep -Seconds 300
}
The usual suspects in Acumatica customizations
- Static event handler subscriptions. A graph extension that subscribes to a static event (on a singleton service, a custom cache, or a third-party SDK) in its constructor without unsubscribing pins the graph instance in memory for the lifetime of the process. Graphs are meant to be short-lived per request; a static subscriber defeats that.
- PXCache references held outside the graph's lifetime. Stashing a
PXCacheor a full row object in a static dictionary "for performance" outlives the request and accumulates one entry per distinct key forever, since nothing ever evicts it. - Unbounded in-memory collections in PXLongOperation callbacks. A long-running background operation that appends to a static
List<T>for logging or progress tracking, and is never cleared after the operation completes, grows with every run. - Un-disposed unmanaged resources. Custom code opening file handles, HTTP clients, or SQL connections outside the framework's managed connection pooling, without a
usingblock, leaks unmanaged memory that the .NET GC cannot reclaim on its own.
Creating a new HttpClient per request in an integration extension — common in webhook or business-event handler code — exhausts available sockets under load even though each instance is individually disposed, because socket teardown is asynchronous (TIME_WAIT). Use a single static HttpClient (it is thread-safe for concurrent requests) or IHttpClientFactory if your customization's hosting model supports it, not a new instance per call.
Capturing and reading a memory dump
When the pattern above is not obviously the cause, capture two dumps of the same worker process spaced an hour apart under load, and diff them:
procdump -ma 3812 dump1.dmp
:: wait an hour under normal load
procdump -ma 3812 dump2.dmp
Open both in WinDbg with SOS, or load them into a managed memory analyzer, and sort by retained size. Object counts that grow roughly linearly with elapsed time or request count — not proportional to active sessions — point straight at the leak; a type held once per logged-in user is expected and not a leak at all.
Mitigation while you find the root cause
A scheduled app pool recycle (Application Pools → Recycling → a fixed time overnight, or a private-bytes threshold as a safety net) buys time without masking the investigation, as long as you treat it as a stopgap and keep the ticket open. Do not let a recycle schedule become the permanent fix — it just delays the crash to a less visible hour and hides the regression from whoever ships the next customization.
Wrapping up
Distinguish warm-up growth from a real leak by watching whether Private Bytes plateaus, suspect static references and un-disposed HTTP/SQL resources in custom code first, and use before/after dumps to confirm rather than guess. A scheduled recycle is a bandage, not a cure.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.