Acumatica · Performance

Scaling Acumatica for 1,000 Concurrent Users

A practical architecture for scaling Acumatica to 1,000+ concurrent users — load balancer, multiple application servers, SQL Server configuration, and the patterns that actually work at scale.

John Kihiu12 min read

Acumatica is a three-tier application — a browser client, a stateless ASP.NET application tier, and SQL Server holding the data — and each tier fails differently under load. Getting to 1,000 concurrent users is less about a single big machine and more about removing the bottleneck that shows up first: usually SQL Server contention, then application-tier memory, then the load balancer's session handling. This is the architecture I reach for and the order in which the constraints actually bite.

What "1,000 concurrent" actually means

The first thing to pin down is the definition, because sizing hinges on it. A thousand named users is very different from a thousand users with a request in flight at the same instant. In practice, active concurrency on an ERP is a fraction of the logged-in population — most sessions are reading, thinking, or idle between saves. A tenant with 1,000 named users typically peaks at 150–300 simultaneous requests. Size for the peak request rate and the peak working set, not for the headcount, and you avoid buying servers to sit idle.

The topology that scales

The application tier is stateless per request but keeps a session, so it scales horizontally: put two or more application servers behind a load balancer, keep SQL Server on its own dedicated box (or managed instance), and never co-locate SQL with the application tier once you are past a couple of hundred users. The web tier is CPU- and memory-bound; SQL is I/O- and lock-bound. Sharing a machine means the two starve each other exactly when you need both.

TEXT · TOPOLOGY
              [ Load balancer ]  (sticky sessions, health probe on /)
                 /          \
        [ App server 1 ]   [ App server 2 ]   ... (add nodes for CPU/RAM)
                 \          /
                  [ SQL Server ]   (dedicated, fast storage, ample RAM)
                        |
                  [ Redis / shared cache ]  (optional, for out-of-proc session)

Acumatica keeps a good deal of state in the application tier's in-memory cache. Adding a second node does not automatically double throughput — it splits sessions, so each node caches a different slice of tenants and slices. That is fine, but it means the load balancer must keep a user pinned to one node for the life of the session.

Load balancer and sticky sessions

Acumatica sessions are affinity-sensitive. Configure the load balancer for sticky sessions (session affinity by cookie) so a user's requests always land on the node that holds their session and warm cache. Without affinity you get intermittent re-authentication and slow first requests as each node cold-loads the user's context. Health probes should hit a lightweight endpoint and drain a node before you patch it, otherwise you drop live sessions during a rolling update.

Don't round-robin an ERP

Plain round-robin without affinity is the single most common scaling mistake here. It works in a demo and falls apart under real session load: users bounce between nodes, caches never warm, and login storms hit SQL. Turn on cookie-based affinity before you add the second node, not after.

SQL Server is almost always the first bottleneck

On every scaling engagement I have done, the wall you hit first is SQL Server — usually lock contention on hot tables and a handful of missing indexes, not raw CPU. Give SQL enough RAM to keep the working set in the buffer pool, put tempdb on fast storage with multiple files, and set MAXDOP and cost-threshold sensibly rather than leaving the defaults. Then find the queries actually costing you time before you tune anything blindly:

SQL · DIAGNOSTIC
-- Top statements by average CPU, from the plan cache
SELECT TOP 20
    qs.total_worker_time / qs.execution_count      AS avg_cpu_us,
    qs.total_elapsed_time / qs.execution_count     AS avg_dur_us,
    qs.execution_count,
    SUBSTRING(st.text, 1, 200)                     AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY avg_cpu_us DESC;

Read Committed Snapshot Isolation (RCSI) is worth serious consideration on a busy Acumatica database: it lets readers avoid blocking writers by reading a row version instead of waiting on a shared lock, which removes a whole class of reader-writer deadlocks that show up under concurrency. Test it on a copy first — it shifts load onto tempdb — but it is often the change that buys the most headroom.

Application-tier sizing and cache

Each application node needs enough RAM to hold the cached metadata and per-session working set for the users pinned to it. Memory, not CPU, is usually the binding constraint on the web tier — watch for the app pool recycling under memory pressure, which dumps every warm cache on that node and sends a burst of cold requests to SQL. Give the pool a generous private-memory limit, disable idle timeouts that kill warm sessions, and scale out (more nodes) rather than up once a single node is comfortably provisioned.

Cache is per node, per graph

Acumatica's slot/cache is scoped to the application node and to each graph instance. Two nodes hold independent caches, so a change committed on node A is visible to node B only through the database, not through memory. This is normal and correct — just don't expect an in-memory cache invalidation to propagate across the farm.

Reports and integrations: keep them off the interactive tier

The load that quietly breaks interactive users is rarely their own clicks — it is a long-running report or a chatty integration hammering the same nodes. Push heavy reporting and bulk API traffic onto a separate application node (or a dedicated integration endpoint) so a 200,000-row export or an overnight sync does not compete with someone trying to save an invoice. Rate-limit external integrations and prefer the batched, filtered contract-based endpoints over per-record calls; the difference between one filtered request and a thousand chatty ones is the difference between a healthy farm and a saturated one.

Symptom under loadMost likely causeFirst fix
Saves and lists slow for everyone at onceSQL lock contention / missing indexFind the hot query, add the index, consider RCSI
Random re-logins, slow first requestNo session affinity on the balancerEnable cookie-based sticky sessions
Periodic latency spikes on one nodeApp pool recycling under memory pressureRaise memory limit, add a node, disable idle recycle
Everything slows during a report windowReporting sharing the interactive tierMove reports/integrations to a dedicated node

Wrapping up

Scaling Acumatica is an exercise in finding the current bottleneck and moving it, not in over-provisioning everything at once. Start with a dedicated, well-indexed SQL Server, put the application tier behind a load balancer with real session affinity, keep reporting and integration load off the interactive nodes, and measure before every change. Do that and 1,000 users is an ordinary Tuesday rather than a Friday-evening incident. If you are sizing a specific deployment, reach out or keep reading through the rest of the Acumatica blog.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.