Legacy .NET modernization: how we made contract generation 40× faster
A large Czech bank spent 2+ hours generating contracts, CPU at 95%. After the refactor: 3 minutes, CPU 15%. Here is how we found the bottleneck and fixed it.
Contract generation took over two hours. The server CPU ran at 95%. The queue kept growing, bank staff were waiting, and on weekends a nightly batch blocked every other system. That was the starting point when a large Czech bank asked us to take a look.
This is not unusual. Older .NET systems in banking, insurance, and manufacturing follow the same pattern: everything works until volume grows. Then it starts grinding — and every fix adds another workaround instead of addressing the actual cause.
Symptoms that say "this is not normal"
Recognizing there is a problem is easy. The harder part is not jumping at the first explanation. Typical legacy performance symptoms:
- Batch jobs that scale with data. They took 20 minutes last year, now 3 hours — and next year? Nobody measured.
- CPU that spikes on one specific operation. Not continuously, but one request type chokes the server.
- Timeouts that "occasionally happen". The app works, but every so often something misses its deadline — and nobody knows exactly why.
- Code nobody wants to open. The critical method is 800 lines, comments from 2011, and everyone leaves it alone so they do not "break production".
At the bank it was clear: 2+ hours for a contract batch, CPU never below 80% during processing. But why?
Do not guess — profile
The most common mistake when optimizing is implementing a solution before you know where the real bottleneck is. Experience leads to hypotheses — but hypotheses are a bad foundation for refactoring production code.
The right approach:
- Profiler, not intuition. dotnet-trace, PerfView, or Application Insights show where time is actually spent. Not where you think it is spent.
- Measure specific metrics. Number of SQL queries per request, memory allocations, response times per operation. Numbers, not feelings.
- Look for anomalies, not just slow parts. A method that takes 50 ms but is called 80,000 times per batch is a bigger problem than one that takes 2 seconds and is called once.
In the bank's case the profiler was unambiguous: the code was iterating over the same list thousands of times. Not once — thousands of times. For every record it scanned the entire collection to find a match. A classic O(n²) problem hiding inside perfectly ordinary-looking code.
The concrete techniques that made the difference
HashSet and Dictionary instead of List for O(1) lookup
The core problem at the bank: the code used List<T>.Contains() to search a collection of thousands of records. Each Contains walks the entire list — O(n). Do that 50,000 times and you have O(n²).
The fix: replace search collections with HashSet<T> or Dictionary<TKey, TValue>. Lookup is O(1) regardless of collection size. This single change cut processing time by more than half.
Dictionary cache for repeated computations
The second major source of waste: the same calculations were performed over and over for the same inputs. Every record loaded and recomputed data that does not change for a given contract type.
The fix: memoization via Dictionary. A result is computed once, stored under a key, and returned immediately on the next hit. For longer lifetimes or cross-instance sharing, Redis.
Batch processing instead of row-by-row
The legacy code processed records one at a time, each with its own SQL query. 10,000 records = 10,000 round trips to the database.
The fix: load data in a batch (WHERE id IN (...)), process in memory, save in a batch. The number of database queries dropped from thousands to dozens.
Indexes and SQL query rewrites
The SQL profiler revealed full table scans on tables with millions of rows. Indexes were missing on columns used in WHERE clauses. Adding the right indexes and rewriting a handful of queries (removing unnecessary SELECT *, using projections) cut query times by 10–20×.
The result: 40× faster
After all changes were in place:
- Contract generation: 2+ hours → 3 minutes
- CPU during processing: 95% → 15%
- The nightly batch stopped blocking other systems
No platform replacement. No rewrite into microservices. Same .NET codebase, same database. Just fixing actual root causes instead of ordering more hardware.
The principle: fix the cause, not the symptom
This is the key point: every problem above had a "quick fix". Add a server. Increase the timeout. Run the batch at night when load is lower. Those patches would have worked — for a few months. Then the problem would be twice as large.
An O(n²) algorithm does not change when you add RAM. Thousands of unnecessary SQL queries do not speed up with better hardware. The bottleneck always moves somewhere else.
Atlas Copco confirms this from a different angle: we reduced their SAP integration pipeline from 15,000 to 3,000 lines of SQL and moved to event-driven architecture. Result: 80% less code, a system that is actually maintainable.
How to tell if you have the same problem
Work through these questions:
- How many SQL queries does your main batch execute per processed record?
- Are your key collections
List<T>orHashSet/Dictionary? - When did you last run a profiler against production load?
- Does your processing time grow linearly with data, or faster?
If you do not know the answers — or the answers are uncomfortable — it is time to change that.
We have hands-on experience modernizing legacy .NET systems in regulated industries. See our modernization service or contact us directly — we will show you where your system is vulnerable.