Most ERP performance problems aren’t discovered during development. They show up three months after go-live when the accounting team is running month-end close and everything grinds to a halt. By then, the client is frustrated, your deployment is under scrutiny, and you’re debugging a production system with actual business operations happening on top of it.
That’s the worst possible time to think about performance. This post is about what to actually look at before you get there. Not theoretical optimization. Not “use indexes” advice that everyone already knows. The real stuff that bites you in multi-module ERP deployments.
Start With What You’re Actually Measuring
Before tuning anything, you need to know what “slow” means in your specific system. Vague complaints like “the system feels sluggish” are nearly impossible to act on.
Get specific. Is it slow on list views with lots of records? Is it a particular form that loads related data from four modules? Is it a background job that blocks other operations? The fix for each of those is completely different.
Set up basic request timing early. You want to see which endpoints are taking the most time, how that changes under concurrent load, and whether slowdowns are consistent or spike-based. If you don’t have this visibility before go-live, you’re flying blind.
The Difference Between Perceived and Actual Latency
A page that takes 600ms to respond but renders instantly feels fast. A page that responds in 200ms but spins a loading indicator for two seconds feels slow. These are different problems.
Actual latency is your server-side concern. Perceived latency is a frontend concern. Don’t conflate them. When someone says the ERP is slow, figure out which one you’re actually dealing with before you start changing things.
Database Queries Are Almost Always the Problem
Nine times out of ten, ERP performance problems trace back to the database layer. Not the application logic. Not the server resources. The queries.
The reason is structural. ERP systems are inherently relational. A sales order touches products, customers, pricing rules, inventory locations, tax configurations, and fulfillment records. Every one of those relationships is a potential query. And in a system built without careful query management, those relationships get resolved lazily, one at a time, every time.
The classic failure mode is what most people call the N+1 problem. You load a list of 50 orders. For each order, the system separately queries the customer name. That’s 51 queries for what should be one. It feels fine in development with five records. It’s brutal in production with 5,000.
What Automatic Prefetch Actually Solves
Fullfinity’s access-aware ORM handles prefetching automatically based on what fields are actually needed for a given view or operation. You’re not manually annotating every query to say “also load the related customer.” The system figures out the access pattern and batches the fetches accordingly.
This matters a lot in practice because developers don’t always know upfront which views will request which related fields. You build the model, someone builds a list view that shows five related fields, and suddenly you have a query pattern nobody planned for. Automatic prefetch means that pattern gets handled efficiently without a code change.
That said, automatic optimization doesn’t replace understanding. If you’re building custom modules or complex reporting views, you should still think about what data you’re pulling and whether you actually need all of it. More on that shortly.
For a more detailed breakdown of how the ORM layer approaches this, the Access-Aware ORM post goes much further into how selective field loading works.
Schema Design Decisions That Hurt You Later
A lot of performance problems are baked in at the data modeling stage. By the time you’re seeing them in production, the data is already there and migration is painful.
The most common one is over-normalization without thinking about read patterns. Normalizing your schema is good practice. But ERP systems are read-heavy. If your most common operation is reading order summaries and that requires joining seven tables, you’ve created a structural performance problem that indexes alone won’t fully solve.
Think about your actual query patterns when you’re designing models. What does the list view need? What does the detail view need? What does reporting need? Design your schema to make those operations reasonable, not just to satisfy normalization theory.
Soft Deletes and Archived Records
Most ERP systems use soft deletes. Records aren’t actually removed from the database. They’re flagged as inactive or archived. This is fine and often necessary for audit reasons.
But it creates a filter condition that gets applied to nearly every query. As your archived record count grows, this becomes a real issue. Tables fill with records that are queried constantly but rarely returned.
The answer isn’t to remove soft deletes. It is to think about partitioning active and archived data as volumes grow, and to make sure your indexes support the is_active or equivalent filter efficiently.
Also worth noting: archiving strategies in ERP need to account for module interdependencies. An archived customer might still be referenced by open invoices. Your schema and query logic need to handle that gracefully.
Background Jobs and the Blocking Problem
ERP systems run a lot of background work. Inventory sync. Invoice generation. Report compilation. Email notifications. If any of these run synchronously in response to a user action, you’re going to have problems.
The rule is simple: anything that could take more than a couple hundred milliseconds should be deferred to a background queue. Users shouldn’t be waiting for an email to send before they get a confirmation screen.
But background jobs create their own performance issues. Jobs that run too frequently. Jobs that aren’t idempotent and stack up when the queue backs up. Jobs that lock rows in the database while they run. These are subtle and hard to debug because they’re not user-facing.
Concurrency and Async Operations
Fullfinity is built on async Python with FastAPI, which means the application layer doesn’t block while waiting for I/O. A database query, an external API call, a file read, none of these block the thread. The server can handle other requests while waiting.
This is a meaningful architectural advantage for ERP workloads because ERP is heavily I/O bound. You’re constantly talking to the database, often waiting on external services (payment processors, shipping APIs, tax services), and generating files. A blocking architecture wastes time at every one of those points.
But async helps you the most when your bottlenecks are I/O, not CPU. If you have a report that does heavy in-memory computation on large datasets, async won’t fix that. CPU-bound work still needs to move to background tasks or be optimized algorithmically.
The Async Python in ERP post covers the throughput implications of this in much more detail if you want to go further on the architecture side.
What Module Count Does to Performance
This is something consultants especially need to think about. Each module you install adds database tables, hooks into shared events, and potentially adds middleware or background jobs. The modules don’t run in isolation.
A well-designed module adds minimal overhead when it’s not in active use. But poorly scoped modules, especially ones that register event listeners on high-frequency operations, can add measurable latency across the system even when their features aren’t being used.
The guidance here isn’t to minimize module count for its own sake. Install what the business needs. But review what each module is actually doing at runtime, not just what features it provides to users. If a module hooks into the order save event to check something, and orders are saved thousands of times a day, that check better be fast.
We covered the decision framework for module installation in detail in ERP Module Installation: What to Actually Think About Before You Add Another Module. The performance angle is one part of that decision, but it’s an important one.
Cross-Module Query Complexity
When multiple modules interact, the query complexity goes up. A sales order in a system with CRM, inventory, accounting, and fulfillment modules installed means one record touches data owned by four different modules.
This is expected in an ERP. It’s the whole point. But you should understand the query depth of your critical business flows. Walk through your most common operations and map out what data they touch. If the order confirmation flow is joining twelve tables, that’s worth knowing before you go to production.
The benefit of a truly modular system is that you can reason about each module’s data access independently. The downside is that cross-module interactions can be harder to trace when something goes slow.
Caching: Where It Helps and Where It Creates Problems
Caching is tempting. Slow query? Cache the result. But in ERP, caching is genuinely hard to do correctly because the data changes constantly.
Caching static reference data is usually safe. Tax rate tables. Product categories. Country and currency lists. These change infrequently and are read constantly. Caching them at the application layer makes sense.
Caching transactional data is dangerous. Order totals. Inventory counts. Account balances. These change with every transaction. A stale cache here doesn’t just look wrong. It causes real business problems. Someone ships an order for a product that shows as in stock but actually isn’t.
Cache Invalidation in Multi-Module Systems
The hard part of caching in ERP isn’t the initial implementation. It’s the invalidation. When a product price changes in the pricing module, any cached data that includes that price needs to be expired. But “any cached data that includes that price” might span five different parts of the system.
If you’re implementing application-level caching, be conservative. Only cache data with a clear invalidation trigger. Avoid time-based cache expiry for anything business-critical. And always build with the assumption that the cache will be wrong sometimes, and have a plan for what happens when it is.
Connection Pooling and Database Resource Management
At scale, how you manage database connections matters as much as what queries you run.
ERP systems under load can easily exhaust a database’s connection limit. Every async request needs a connection from the pool. If your pool is undersized or connections aren’t being released properly, requests start queuing up waiting for a connection. This shows up as latency that seems random and doesn’t correlate with query time.
Set your connection pool size based on your database server’s capacity and your expected concurrency. Don’t just use the default. And instrument pool utilization so you can see when you’re hitting limits.
Also think about long-running transactions. A transaction that stays open while waiting for user input (or while running a slow background process) holds a connection and potentially holds locks. Keep transactions short and scoped. Open late, close early.
Monitoring and Observability: The Thing Most People Skip Until It’s Too Late
You can’t tune what you can’t see. Monitoring isn’t a post-launch concern. It should be in place before you go live.
At minimum, track:
- Request latency by endpoint so you can see which parts of the system are slow
- Database query counts and timing so you can catch N+1 patterns and slow queries
- Background job queue depth and processing time so you know if work is backing up
- Error rates because errors often correlate with performance issues
- Database connection pool utilization so you can catch resource exhaustion before it becomes critical
A lot of teams skip this during initial setup because it feels like overhead. But the first time a client calls you with a performance complaint and you have no data to work from, you’ll wish you’d set it up.
Set up alerting too, not just dashboards. You want to know when latency spikes above a threshold, not find out by checking a dashboard two days later.
Conclusion
ERP performance isn’t something you fix after the fact. By the time your client is complaining, you’re already in a bad position. The work happens before go-live, in the design and configuration decisions that determine what your system looks like under real load.
The three things I’d focus on first:
-
Get visibility before you ship. Request timing, query counts, and connection pool metrics should be instrumented before the system goes to production. Everything else depends on having this data.
-
Treat the database as your primary constraint. Most ERP performance problems live in the query layer. Understand your access patterns, make sure prefetching is working as expected, and design your schema with read patterns in mind.
-
Keep transactions short and background jobs truly async. These two things prevent a huge category of concurrency and blocking problems that are annoying to debug and easy to avoid.
If you’re building on Fullfinity, a lot of the lower-level optimization (query batching, schema management, async I/O) is handled by the platform. That’s the point. But understanding what the platform does and why still matters, because the architectural decisions you make on top of it can undo those gains quickly.
Explore how Fullfinity approaches the performance layer, or dig into the full blog if you want to go further on specific topics.