Business Central online is fast until someone writes a loop that reads the item ledger one record at a time. The ten performance mistakes we find most in per-tenant extensions, from missing SetLoadFields to locking the whole sales line table, each with the fix.
Most Business Central performance complaints are not the platform. They are an extension doing something expensive in a hot path: a posting routine, a page trigger, a report, an API call. These are the ten we find most often when a customer asks why month-end has got slower, in rough order of how much they cost.
Customer.Get(No) pulls every field, including the blobs and the calculated ones you then CalcFields. Use SetLoadFields(Name, "Credit Limit") before the read so only those columns come back. In a loop over thousands of records it is the single biggest win available, and the newer base application does it everywhere.
For each sales line, Item.Get, then ItemLedgerEntry.SetRange, then CalcSums. That is three round trips per line; a 500-line order is 1,500 queries. Pull the items you need in one filtered read into a temporary table or a dictionary, pull the ledger sums with one CalcSums over a filter that covers all the items, then loop in memory.
A SetRange on a non-key field is a table scan. Check the keys on the table (and the SQL index they produce); if your filter is a common one, add a key in your extension, or better, a key that includes the fields you then read so SQL can satisfy the query from the index.
CalcSums and CalcFields without SIFTFlowFields and CalcSums are fast when there is a SumIndexField on a key covering the filter; they are a full aggregate query when there is not. Define the SIFT key (SumIndexFields on a key with the filter fields) in your table or table extension, and use MaintainSIFTIndex deliberately.
FindLast on an unsorted setFindLast without a key that matches the sort order forces SQL to sort the whole result. Set the key first. Same for FindFirst when you only need existence: use IsEmpty, which asks SQL "is there one" rather than "give me one".
LockTable or FindSet(true) early in a routine takes a lock that every other user then queues behind. Read with FindSet() (read-only), and take the update lock at the last moment, on the narrowest filter. Posting routines that lock Sales Line for the whole company while they loop are the usual cause of "the system froze at month-end".
OnAfterGetRecord on a list pageAnything in OnAfterGetRecord runs for every row the page shows, every time it scrolls. A CalcFields or a lookup there turns a list into a crawl. Move it to a FlowField with a SIFT key, a query object, or a field maintained at write time.
Commit inside a loop makes every iteration a transaction, which is slow and leaves the data half-written if the loop fails. Let the transaction scope do its job. If a long batch genuinely needs checkpoints, batch them in hundreds and know why.
A report with Customer, then Sales Invoice Header, then Sales Invoice Line as nested data items, with the filtering done in triggers, reads everything and discards most of it. Push the filters into the data item properties (DataItemLink, DataItemTableView) so SQL does the restriction, or use a query object as the source.
Every tenant can stream telemetry to Application Insights: long-running SQL, locks, page load times, slow reports, by object and by user. It tells you exactly which codeunit and which query, with the AL call stack. Most businesses have never switched it on. Switch it on (one setting in the admin centre), and look at the "long running SQL queries" and "database locks" signals monthly. The performance work finds itself.
Switch on telemetry. Run the Performance Profiler on the slow process (it is built into the client) and read the call tree. Then apply the list above to the top three entries. In our experience the first afternoon of this on a slow system finds a 5x improvement on the worst process, and it is nearly always numbers 1, 2 and 6. The AL code check does exactly this on a fixed scope.