📊 Power BI

Why Is My Power BI
Dashboard Slow?

A report that opened instantly last year now takes thirty seconds. Nothing was rebuilt. The usual conclusion is that there is too much data now and the fix is more capacity.

Occasionally that is true. Far more often the model is asking the engine to do work it was never designed for, and capacity does not fix a design problem — it just makes it more expensive. Here are the four causes that account for most of what I find, and how to tell which one is yours.


It Is Almost Never the Data Volume

The Power BI engine is columnar and compresses aggressively. A well-built model holding fifty million rows can outperform a badly built one holding two million, because compression depends far more on the shape of your data than on the amount of it.

That is why the symptom is usually gradual rather than sudden. The design was always inefficient; it simply stayed under the threshold where anyone noticed until the data grew. Adding capacity moves the threshold. It does not remove it.

💡 The quickest sanity check: if your .pbix is over 500 MB with fewer than ten million fact rows, you have a modelling problem, not a volume problem.

Cause 1: Calculated Columns on a Fact Table

The single most common cause, and the one most often introduced by people coming from a spreadsheet background — where adding a column is the natural move.

A calculated column is computed for every row at refresh and stored in the model. It consumes memory permanently, it cannot respond to a filter the user applies, and its cost scales directly with row count.

Wrong: stored per row, forever

// Calculated column on a 40-million-row fact table
Margin Amount = Sales[Revenue] - Sales[Cost]
Margin Pct    = DIVIDE( Sales[Margin Amount], Sales[Revenue] )
Order Month   = FORMAT( Sales[OrderDate], "MMMM" )

// Three columns x 40M rows, computed at every refresh,
// held in memory permanently, none of them filter-aware.

Right: computed at query time, in context

Margin Amount = SUM( Sales[Revenue] ) - SUM( Sales[Cost] )

Margin Pct =
DIVIDE(
    [Margin Amount],
    SUM( Sales[Revenue] )
)

// Order Month belongs on the date dimension,
// where it exists once per date rather than once per sale.

The rule: if a value depends on what the user is looking at, it must be a measure. If it is descriptive, it belongs on a dimension. Very little genuinely belongs as a calculated column on a fact table.

Cause 2: One Wide Flat Table

Flattening everything into a single table feels simpler and it is how most people start. It performs badly past a few million rows, and the reason is compression.

Columnar storage compresses repeated values efficiently. In a flat table, every descriptive attribute repeats on every row — the customer name, the region, the product category, the salesperson, all written out forty million times. Split those into dimensions and each value is stored once.

What the shape change does to size

Flat table
  40,000,000 rows x 60 columns
  Customer name, region, category repeated on every row
  Model size: 1.8 GB

Star schema, same data
  Fact:  40,000,000 rows x 12 columns (keys + measures)
  Dims:  Customer 80k, Product 12k, Date 3.6k, Region 40
  Model size: 420 MB

Same data. Roughly a quarter of the memory,
and queries the engine was actually built to run.

Cause 3: Bidirectional Relationships

Someone turns on bidirectional filtering to make one visual behave, and it works. What is less visible is that the model now has ambiguous filter paths, and the engine has to resolve them on every query — including all the ones that were previously fine.

The symptom is distinctive: the whole report gets slower, not one page, and it happened around the time somebody fixed an unrelated filtering problem.

In most cases the underlying need is better solved with CROSSFILTER inside the one measure that requires it, which scopes the behaviour to where it is needed instead of applying it model-wide.

Scope it to the measure that needs it

// Instead of switching the relationship to both directions:
Customers With Orders =
CALCULATE(
    DISTINCTCOUNT( Customer[CustomerID] ),
    CROSSFILTER( Sales[CustomerID], Customer[CustomerID], BOTH )
)

// Bidirectional behaviour applies inside this measure only.
// Every other query in the model is unaffected.

Cause 4: DAX That Works Row by Row

Iterator functions — SUMX, FILTER, AVERAGEX — evaluate an expression for every row in the table they are given. Sometimes that is exactly what you need. Often it is doing set-based work the slow way.

Wrong: FILTER materialises the whole table

High Value Sales =
CALCULATE(
    SUM( Sales[Amount] ),
    FILTER( Sales, Sales[Amount] > 1000 )
)

// FILTER( Sales, ... ) builds the entire 40M-row table
// in memory before the filter is applied.

Right: a column filter the engine can push down

High Value Sales =
CALCULATE(
    SUM( Sales[Amount] ),
    Sales[Amount] > 1000
)

// The engine applies this at the storage layer.
// Same answer, a fraction of the work.

The general shape: filter on columns rather than tables wherever the logic allows it. Reach for FILTER over a full table only when the condition genuinely needs row context that a column predicate cannot express.

Finding Which One You Have, in About Twenty Minutes

  1. Check the file size against row count. Over 500 MB with under ten million fact rows points at causes 1 or 2.
  2. Open DAX Studio, run VertiPaq Analyzer. It lists every column by size. If the largest are calculated columns, that is cause 1. If the largest are text columns on the fact table, that is cause 2.
  3. Look at the model diagram. Any relationship with arrows on both ends is a cause 3 candidate. Note how many.
  4. Turn on Performance Analyzer in Desktop, refresh the slow page, and sort by duration. Copy the query for the worst visual into DAX Studio and read it. FILTER over a whole table is cause 4.
  5. Record the timings before you change anything. Otherwise you cannot tell whether a fix helped or you just got a warm cache.
💡 Do the measurement before the fixing. Half the time the visual everyone complains about is not the one costing the most, and the expensive one is on a page nobody mentioned.

When It Really Is Capacity

Sometimes the answer genuinely is more capacity, and it is worth knowing what that looks like so you can tell it apart from a model problem.

The signature is uniformity. Every report is slow, not one. Performance degrades at predictable times of day when more people are querying. Refresh and interactive queries compete, so reports slow down while a dataset is refreshing. And your capacity metrics app shows sustained high utilisation rather than spikes.

Compare that with a model problem, which is specific: one report, or one page, or one visual, slow regardless of time of day or who else is using the tenant.

The practical test is to open the slow report on a quiet capacity or in Desktop against the same data. If it is still slow there, capacity is not your problem. That test takes ten minutes and saves a great deal of money, because buying capacity to fix a model problem is the most expensive way to not solve it.

What We Would Do

A performance audit is a fixed-price two-week engagement. We take a copy of the model, run the diagnostics, record baseline timings, and come back with findings ranked by impact against effort — so you can see what a day of work buys before committing to a week.

We also say when the honest answer is a rebuild rather than tuning, and we say it in week one rather than at the end. A model structured wrongly from the start is sometimes cheaper to rebuild than to patch, and you should hear that early enough to act on it.

💬 Working with us

Send the model size, the row count of your largest table, and how long the slowest page takes. You will get an initial read on the likely cause before we discuss an engagement at all. Power BI performance optimization, or run the free 32-point audit checklist first.

Keep Reading

Report taking 30 seconds to open?

Send the model size, largest table row count, and slowest page timing. You will get an initial read on the cause before we talk about an engagement.

Start the Conversation →