D365 Performance Tuning

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.

Identifying Performance Bottlenecks

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.

Common Failure Modes in Query Execution

Optimizing the Database Layer

Indexing Strategy

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;
";

Partitioning Large Tables

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);

Monitoring Index Usage

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.

Optimizing Application Server and X++

Configuring the AOS

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>

Tuning X++ Code

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'));

Reducing AOS Memory Pressure

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>

Batch Job Optimization

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.

Client-Side and API Performance

Reducing Client Latency

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)"

API Pagination and Filtering

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.

Caching Strategies

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

Diagnosing Failed API Calls

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.

Post-Tuning Validation and Monitoring

Continuous Performance Testing

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.

Alerting on Key Metrics

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.

Summary of Sharp Edges

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.