The Telerik Report Triage Checklist
Everything in this course, arranged as a sequence for one situation: a Telerik report in a legacy ASP.NET Core application is slow, and you need to find out why and fix it.
Work through it in order. Each step is cheap relative to the one after it, and each one either answers the question or narrows it. Do not skip ahead to indexes β that is the step most people start at and it is step 7 here for a reason.
1. Establish what "slow" means
- How long does it take, for which parameters, and how long should it take?
- Slow always, or slow sometimes? Sometimes β suspect parameter sniffing (step 6).
- Slow for all parameter sets, or only wide date ranges? Only wide β sniffing or genuine data volume.
- When did it start? Any deployment, data load, or migration around then?
- How fresh does the data need to be? This determines whether step 10 is available to you.
Write the answers down. They are the ticket.
2. Check the server version and Query Store
SELECT @@VERSION, SERVERPROPERTY('ProductMajorVersion'), SERVERPROPERTY('ProductLevel');
SELECT name, compatibility_level, is_query_store_on, is_read_committed_snapshot_on
FROM sys.databases WHERE name = DB_NAME();
Note the major version and the compatibility level β they decide which tools you have (Module 5 Lesson 1). If Query Store is off, turn it on now, because everything after this is easier with it and it needs time to collect.
3. Split the time four ways
Get the exact SQL the report's dataset sends (Telerik report designer β the data source's SelectCommand, or capture it from the plan cache / Extended Events), and run it in SSMS with the real parameters.
SET STATISTICS TIME ON;
SET STATISTICS IO ON;
-- the report's dataset query, with production parameter values
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
- Fast in SSMS, slow in the report β the time is in transfer or rendering. Go to step 4.
- Slow in SSMS, CPU β elapsed β the query is the problem. Go to step 5.
- Slow in SSMS, CPU βͺ elapsed β it is waiting. Go to step 8.
4. Count the rows the report actually receives
This is the highest-yield check for a Telerik report specifically.
SELECT COUNT_BIG(*) AS rows_sent_to_report
FROM ( /* the report's dataset query, unchanged */ ) AS q;
Compare that number to how many rows the report displays. If the dataset returns 400,000 detail rows to render a few dozen summary lines, the report engine is doing the grouping, filtering and sorting client-side and that is your problem. Confirm with wait stats while the report runs:
SELECT session_id, wait_type, wait_time/1000.0 AS wait_seconds,
total_elapsed_time/1000.0 AS elapsed_seconds, cpu_time/1000.0 AS cpu_seconds
FROM sys.dm_exec_requests
WHERE session_id > 50 AND session_id <> @@SPID
ORDER BY total_elapsed_time DESC;
ASYNC_NETWORK_IO dominating confirms it.
Fix: move the aggregation into SQL (GROUP BY, ROLLUP, window functions), trim the SELECT list
to the columns the report renders, push report-level filters into the WHERE clause, and make
drill-down a separate parameterized query. Module 6 Lesson 2.
This step alone resolves a large share of slow Telerik reports, and it requires no database change.
5. Get the actual execution plan and check the warnings
Run the dataset query with Ctrl+M on. Check, in this order:
- Warning triangles. Implicit conversion, spills to tempdb, excessive memory grant, missing index, no join predicate. (Module 2 Lesson 5.)
- Estimated vs actual rows at the operator producing the most rows. A large divergence is a cardinality problem, and everything downstream follows from it. (Module 2 Lesson 2.)
- Key/RID Lookups and their execution count. High counts mean the index does not cover the query. (Module 2 Lesson 3.)
- Scans where you expected seeks.
Ignore the cost percentages. They are estimates and frequently wrong.
6. Check the query text for the two most likely causes
(a) The catch-all filter pattern. Does the WHERE clause look like this?
WHERE (@RegionId IS NULL OR RegionId = @RegionId)
AND (@CustomerId IS NULL OR CustomerId = @CustomerId)
AND ...
If the report has a panel of optional filters, it very likely does. One cached plan must be valid for
every combination, so it scans and none of your indexes get used β which is why the report is slow
even when the user filters down to four rows. Fix: OPTION (RECOMPILE). (Module 4 Lesson 6.)
(b) Implicit conversion from .NET. Does the plan show CONVERT_IMPLICIT on a column, or a
cardinality-estimate warning on a scan? SqlClient sends NVARCHAR by default; comparing to a
VARCHAR column converts the column and prevents the seek. Fix: explicit SqlDbType.VarChar
parameters, or IsUnicode(false) in the EF mapping. (Module 4 Lesson 3.)
Sweep for both across the whole application while you are here:
SELECT TOP (25) SUBSTRING(t.text,1,300) AS query_snippet, qs.execution_count,
qs.total_elapsed_time / NULLIF(qs.execution_count,0)/1000 AS avg_ms
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS p
WHERE CAST(p.query_plan AS nvarchar(max)) LIKE '%PlanAffectingConvert%'
ORDER BY qs.total_elapsed_time DESC;
Then check the rest of the SARGability list: functions on columns in the WHERE clause
(YEAR(OrderDate) = 2026 β half-open range), leading-wildcard LIKE, scalar UDFs, SELECT *.
(Module 4.)
7. Check for parameter sniffing
If the report is fast sometimes and slow other times with no code change:
SELECT qsp.query_id, qsp.plan_id, SUM(rs.count_executions) AS execs,
AVG(rs.avg_duration)/1000.0 AS avg_ms, AVG(rs.avg_logical_io_reads) AS avg_reads
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_plan AS qsp ON qsp.plan_id = rs.plan_id
WHERE qsp.query_id = 1234 -- placeholder: your query_id
GROUP BY qsp.query_id, qsp.plan_id
ORDER BY avg_ms DESC;
One query_id with multiple plan_ids and very different durations confirms it.
Fix: OPTION (RECOMPILE) if you can edit the SQL. If the SQL lives inside the Telerik report
definition and you cannot easily change and redeploy it, apply the same hint through Query Store:
EXEC sys.sp_query_store_set_hints @query_id = 1234, @query_hints = N'OPTION(RECOMPILE)';
(Module 5 Lessons 3 and 6.)
8. Check statistics and blocking
Statistics first β cheapest experiment available:
SELECT OBJECT_NAME(s.object_id) AS table_name, s.name AS stats_name,
sp.last_updated, sp.rows, sp.rows_sampled, sp.modification_counter
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id = OBJECT_ID('dbo.YourReportTable') -- placeholder
ORDER BY sp.last_updated;
-- If stale:
-- UPDATE STATISTICS dbo.YourReportTable WITH FULLSCAN;
Blocking, if CPU was much less than elapsed in step 3:
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time/1000.0 AS wait_seconds,
SUBSTRING(t.text,1,200) AS blocked_query
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.blocking_session_id <> 0;
If the report is blocked by, or blocking, the application: enable READ COMMITTED SNAPSHOT. Do not
add NOLOCK β it can silently return duplicated or missing rows, which on a report means wrong
numbers with no error. (Module 6 Lesson 4.)
9. Now design indexes
Only now, with the query SARGable, the plan stable and the row count sensible.
- Inventory what the table already has before adding anything.
- Key order: equality columns first, then the range column, then INCLUDE for coverage.
- Aim to eliminate the Key Lookups you found in step 5.
- Treat the plan's missing-index suggestion as a hint about which columns the optimizer wanted to seek on β not as a statement to run. It ignores existing indexes, its column order is not chosen for selectivity, and it includes every column in the SELECT list.
- Check
sys.dm_db_index_usage_statsa week later to confirm the new index is actually being used.
(Module 3.)
10. Structural options, if it is still slow
At this point the query is well-written and correctly indexed, and it is slow because the work is genuinely large. Revisit the freshness answer from step 1:
- Nightly freshness acceptable β pre-aggregated reporting table refreshed by a SQL Agent job. Usually the best cost/benefit for a scheduled or dashboard report.
- Near-real-time needed, moderate write volume β indexed view.
- Large fact table, scan-and-aggregate β nonclustered columnstore index.
- The report is interfering with the application β readable secondary or a database snapshot.
(Module 6 Lesson 5.)
11. Prove it, and write it down
SELECT CAST(rsi.start_time AS date) AS day, qsp.plan_id,
SUM(rs.count_executions) AS execs,
AVG(rs.avg_duration)/1000.0 AS avg_ms,
AVG(rs.avg_logical_io_reads) AS avg_reads
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_runtime_stats_interval AS rsi
ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
JOIN sys.query_store_plan AS qsp ON qsp.plan_id = rs.plan_id
WHERE qsp.query_id = 1234 -- placeholder
AND rsi.start_time >= DATEADD(DAY, -14, SYSDATETIMEOFFSET())
GROUP BY CAST(rsi.start_time AS date), qsp.plan_id
ORDER BY day;
Record: what you measured before, what you changed, what you measured after, and the before/after
.sqlplan files. Report logical reads alongside duration β it is the number that does not move with
server load, so it is the honest evidence.
The condensed version
- Define "slow" and check data-freshness requirements.
- Version, compatibility level, Query Store on.
- Run the query in SSMS: split query time / waiting / transfer / rendering.
- Count the rows the report receives versus what it displays.
- Actual plan: warnings, estimated vs actual, lookups.
- Query text: catch-all filters, implicit conversion, SARGability.
- Query Store: one query, multiple plans β sniffing.
- Statistics staleness; blocking β RCSI, not NOLOCK.
- Design indexes deliberately.
- Structural options: pre-aggregation, indexed view, columnstore, readable secondary.
- Measure again and document.
In practice
Here is how the whole checklist looked on one report. A Telerik report was taking about ninety
seconds. Rather than guessing, the time was split first β SET STATISTICS TIME showed CPU well
below elapsed, and the query returned about four hundred thousand rows to render a summary, with
ASYNC_NETWORK_IO dominating the waits. So most of the time was transfer and client-side
grouping, not the query. Moving the aggregation into SQL with GROUP BY and window functions took
the result set to a few dozen rows. The remaining server time was a catch-all WHERE clause with
optional parameters producing one plan for every combination; OPTION (RECOMPILE) let the
optimizer fold out the unused branches and use the indexes. Query Store showed average duration
going from forty-one seconds to about one point two, and logical reads from two million to eight
thousand, measured over a week of real production runs.