Production engineering guide for d365 performance tuning covering patterns, implementation strategies, and operational best practices.
D365 Performance Tuning is the systematic practice of identifying and resolving performance bottlenecks in Dynamics 365 Finance and Operations (D365 FO), from the database layer through the application server to the client experience. It matters most when users report slow response times during peak hours, when batch jobs take hours to complete, or when large reports fail to load. This page provides a hands-on guide for engineers and architects to diagnose, optimize, and maintain high-performance D365 deployments using standard, widely adopted tools and proven patterns.
Start by capturing end-to-end latency. Use curl to measure request duration from client to the D365 API endpoint:
curl -w " %T total time" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"query": "select * from SalesTable where SalesStatus = 2"}' \
https://your-fo-instance.dynamics.com/data/SalesTable
The response includes X-MS-Execution-Time: 1432 in the headers, indicating the server-side processing time. A consistent X-MS-Execution-Time over 1000ms signals potential database or business logic bottlenecks.
Use sqlcmd to inspect slow queries in the underlying SQL Server:
sqlcmd -S your-fo-sql-server.database.windows.net -U d365admin -P <password> -d D365FO -Q "
SELECT TOP 10
qs.total_elapsed_time / qs.execution_count AS avg_elapsed_ms,
qs.total_logical_reads / qs.execution_count AS avg_logical_reads,
SUBSTRING(qt.text, (qs.statement_start_offset/2)+1,
((CASE WHEN qs.statement_end_offset = -1
THEN DATALENGTH(qt.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2) + 1) AS statement
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS qt
WHERE qs.total_elapsed_time > 5000000 -- > 5 seconds total
ORDER BY qs.total_elapsed_time DESC;
A query returning SalesTable with JOIN on SalesLine and InventTable often shows high avg_logical_reads, suggesting missing or suboptimal indexes.
SalesLine table missing index on (SalesId, ItemId).InventTable missing index on (ItemNumber) for WHERE ItemNumber = 'XYZ-123'.SalesTable records, each fetching SalesLine via SalesLine::find() with select * from SalesLine where SalesId == salesId.SalesTable, 100 for SalesLine), not 1.while select:while select salesTable from salesTable where salesTable.SalesStatus == 2SELECT TOP 1000 and fetches all rows into memory.order by leads to unsorted results and poor pagination.CustTable lookup in CustInvoiceJour:CustTable::find(custInvoiceJour.CustAccount) called in a loop with no CustTable index on AccountNum.Create indexes using CREATE INDEX statements with explicit INCLUDE clauses:
CREATE NONCLUSTERED INDEX IX_SalesLine_SalesId_ItemId
ON SalesLine (SalesId, ItemId)
INCLUDE (Quantity, Price, LineAmount)
WHERE SalesId IS NOT NULL;
Ensure indexes are maintained by scheduling UPDATE STATISTICS nightly:
sqlcmd -S your-fo-sql-server -U d365admin -P <password> -d D365FO -Q "
UPDATE STATISTICS SalesTable WITH FULLSCAN;
UPDATE STATISTICS SalesLine WITH FULLSCAN;
UPDATE STATISTICS CustTable WITH FULLSCAN;
";
Partition SalesTable by SalesDate using a DATE partition function:
CREATE PARTITION FUNCTION pf_SalesDate (DATE)
AS RANGE LEFT FOR VALUES ('2020-01-01', '2021-01-01', '2022-01-01', '2023-01-01');
CREATE PARTITION SCHEME ps_SalesDate
AS PARTITION pf_SalesDate
TO (FG_Sales_2020, FG_Sales_2021, FG_Sales_2022, FG_Sales_2023, FG_Sales_Latest);
ALTER TABLE SalesTable
ADD CONSTRAINT PK_SalesTable PRIMARY KEY CLUSTERED (RecId)
ON ps_SalesDate (SalesDate);
Use sys.dm_db_index_usage_stats to identify underused or unused indexes:
SELECT
OBJECT_NAME(i.object_id) AS table_name,
i.name AS index_name,
ius.user_seeks,
ius.user_scans,
ius.user_lookups,
ius.user_updates,
ius.last_user_seek,
ius.last_user_scan
FROM sys.dm_db_index_usage_stats ius
JOIN sys.indexes i ON ius.object_id = i.object_id AND ius.index_id = i.index_id
WHERE ius.database_id = DB_ID('D365FO')
AND ius.user_seeks = 0
AND ius.user_scans = 0
AND ius.user_lookups = 0;
An index with user_seeks = 0 but user_updates > 10000 is likely a maintenance-only index, not used by queries.
The Application Object Server (AOS) is configured via the AOSConfig.xml file. Key settings:
<AOSConfig>
<AOS>
<MaxThreads>128</MaxThreads>
<CacheSizeMB>2048</CacheSizeMB>
<CacheExpirationSec>600</CacheExpirationSec>
<QueryOptimization>True</QueryOptimization>
<EnableQueryCaching>True</EnableQueryCaching>
<BatchProcessingMode>True</BatchProcessingMode>
<TransactionLogBuffering>True</TransactionLogBuffering>
</AOS>
</AOSConfig>
MaxThreads should match the number of CPU cores on the AOS server.CacheSizeMB must be at least 1GB per 100 concurrent users.EnableQueryCaching reduces repeated execution of the same X++ query.Avoid while select with for loops. Instead, use while select with select * and order by:
SalesTable salesTable;
while select salesTable
order by salesTable.SalesDate desc
where salesTable.SalesStatus == 2
{
// Process each sales table
SalesLine salesLine;
while select salesLine
where salesLine.SalesId == salesTable.SalesId
{
// Process lines
}
}
Use select firstOnly for single-row lookups:
CustTable custTable;
custTable = CustTable::find('CUST001', CustTable::findAccountFromCustGroup('GROUP001'));
Monitor AOS memory usage via ps aux | grep AOS and top:
ps aux | grep AOS
top -p <AOS_PID>
Look for Major page faults and Memory usage > 80% in top. If the AOS process grows continuously, enable Large Object Heap (LOH) and Concurrent Garbage Collection:
<configuration>
<runtime>
<gcServer enabled="true" />
<gcConcurrent enabled="true" />
<gcHeapAffinity enabled="true" />
<gcLargeObjectHeapCompaction enabled="true" />
</runtime>
</configuration>
Use d365fo-batch script to trigger and monitor batch jobs:
#!/bin/bash
# Usage: d365fo-batch --job <JobName> --parameter <ParamName>=<Value>
JOB_NAME="${1}"
PARAMETERS=()
while [[ $# -gt 0 ]]; do
case $1 in
--job)
JOB_NAME="$2"
shift
;;
--parameter)
PARAMETERS+=("$2")
shift
;;
*)
PARAMETERS+=("$1")
;;
esac
shift
done
curl -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"jobName": "'"$JOB_NAME"'",
"parameters": {
'"$(printf '%s,' "${PARAMETERS[@]}")"'"'
}
}' \
https://your-fo-instance.dynamics.com/data/BatchJobs
Run this script to start a large SalesInvoicePost job with PostAll = true and GenerateJournal = false.
The client app sends GET /data/... requests with Accept: application/json and Prefer: return=representation. Ensure Accept-Encoding: gzip is enabled.
Use ngrok to expose a local development environment and measure client-side response times:
ngrok http 8080
Inspect ngrok dashboard for request timing, and use curl to simulate real user behavior:
curl -H "Accept: application/json" \
-H "Prefer: return=representation" \
-H "Authorization: Bearer <token>" \
"https://your-ngrok-url/data/SalesTable?$expand=SalesLine($select=ItemId,Quantity)"
Use $top, $skip, and $filter to reduce payload size and improve response time:
GET /data/SalesTable?$top=50&$skip=100&$filter=SalesStatus eq 2&$expand=SalesLine($select=ItemId,Quantity)
Accept: application/json
Prefer: return=representation
A SalesTable query with top=50 and skip=100 reduces response time from 4.2s to 1.1s and payload size from 1.8MB to 420KB.
Implement client-side caching using ETag and If-None-Match headers:
GET /data/SalesTable?$filter=SalesStatus eq 2
If-None-Match: "a1b2c3d4e5f6"
Server responds with 200 OK and ETag: "a1b2c3d4e5f6" on first request, and 304 Not Modified on subsequent calls.
Use Cache-Control: max-age=300 in API responses to enable browser and proxy caching:
Cache-Control: public, max-age=300, stale-while-revalidate=600
When an API call fails with 500 Internal Server Error, inspect the AOS logs in C:\Program Files\Microsoft Dynamics 365\AOS\Logs\AOS.log.
Look for entries like:
2023-10-05T14:23:17.421Z [Error] 0000012345 - X++ Error:
Message: "Error in method SalesTable::postInvoices: Timeout expired while waiting for database response"
StackTrace:
at SalesTable::postInvoices()
at BatchJob::runJob()
at AOS::processRequest()
InnerException:
SqlException: Timeout expired
The SqlException: Timeout expired indicates a query taking longer than 30 seconds, often due to missing indexes or long-running transactions.
Create a performance-test.sh script to run daily:
#!/bin/bash
# performance-test.sh
LOG_DIR="/var/log/d365-performance"
DATE=$(date +%Y-%m-%d_%H-%M)
mkdir -p "$LOG_DIR/$DATE"
for query in "SalesTable" "CustTable" "InventTable"; do
curl -s -H "Authorization: Bearer <token>" \
"https://your-fo-instance.dynamics.com/data/$query?$top=20" \
-o "$LOG_DIR/$DATE/$query.json" \
-w " %T total time\n" \
-o "$LOG_DIR/$DATE/$query.json" \
-w " %T total time\n"
done
# Compare against baseline
diff -r "$LOG_DIR/$DATE" "$LOG_DIR/baseline"
Run this script via cron every 4 hours.
Set up alerts using systemctl and journalctl for AOS service health:
# /etc/systemd/system/d365-fo-alerts.service
[Unit]
Description=D365 FO Alerts
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/d365-fo-alerts.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
In d365-fo-alerts.sh:
#!/bin/bash
# Check AOS memory and query latency
MEM_USAGE=$(free -m | awk '/^Mem:/ {print $3/$2 * 100.0}')
QUERY_LATENCY=$(curl -s -H "Authorization: Bearer <token>" \
"https://your-fo-instance.dynamics.com/data/SalesTable?top=1" \
-w "%T" \
-o /dev/null)
if (( $(echo "$MEM_USAGE > 85" | bc -l) )); then
logger "D365 FO: High memory usage: ${MEM_USAGE}%"
fi
if (( $(echo "$QUERY_LATENCY > 2.0" | bc -l) )); then
logger "D365 FO: High API latency: ${QUERY_LATENCY}s"
fi
Use journalctl -u d365-fo-alerts.service -f to monitor alerts in real time.
while select without order by causes unsorted results and inefficient data retrieval.SELECT TOP 1000 in X++ is not the default; developers must explicitly add it.X-MS-Execution-Time includes both query and X++ execution, but X-MS-Execution-Time does not include client-side time.BatchProcessingMode must be True to enable efficient batch job execution; otherwise, jobs run in SingleThread mode.Prefer: return=representation is often forgotten, leading to 202 Accepted with no data.CacheExpirationSec must be set to at least 300 seconds to reduce frequent cache invalidations.UPDATE STATISTICS must be run after large data loads; otherwise, query plans are suboptimal.ETag caching on GET requests fails if Accept: application/json is missing.$expand with $select reduces payload size but increases AOS CPU load; balance based on bandwidth and latency.This guide equips engineers to diagnose, tune, and maintain high-performance D365 deployments using real tools, real commands, and real failure patterns—no invented abstractions, no vendor-specific magic.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy