In most business systems, analytics dashboards are an essential part of day-to-day operations. They help managers track revenue, monitor system activity, and understand how the organization is performing.
I once worked on a task to extend an existing dashboard by adding revenue data from features that had been introduced later.
At first, the task looked straightforward. But once I started digging into the implementation, several issues became apparent:
API latency was increasing.
Data was spread across many relational tables.
The amount of data had grown significantly.
The dashboard contained multiple types of information, including KPIs, charts, tables, and heatmaps.
Existing formulas were already running in production and were not good candidates for major changes.
Because delivery time and stability were important, rewriting the entire data computation flow was not a practical option.
Instead, the new logic had to be added on top of the existing formulas and grouped with the current results.
That created an important constraint:
I could not significantly change how the data was produced. Most of the optimization had to happen around or after the existing computation flow.
This article focuses on that kind of problem.
1. Don’t Recompute What You Already Know
One of the first optimizations to consider is caching the final result at the API layer.
A basic cache-aside flow looks like this:
Request
↓
Check cache
↓
Cache hit ─────────→ Return
↓
Cache miss
↓
Compute
↓
Store in cache
↓
ReturnThe main advantages are straightforward:
Easy to introduce incrementally.
Low implementation overhead.
Minimal changes to the existing architecture.
Only frequently requested data ends up being cached.
However, caching does not completely solve the latency problem.
On a cache miss:
Cache hit → 50ms
Cache miss → 5sThe request still pays the full computation cost.
Another issue is cache stampede.
If the cache expires and 20 requests arrive at nearly the same time, the system may end up doing:
20 requests = 20 expensive computationsA useful technique here is request deduplication, often called single-flight.
Instead of:
R1 → compute
R2 → compute
R3 → compute
...the system can allow one request to perform the computation while the others wait for the same result:
┌─ R1 computes
R2 ────────┤
R3 ────────┤ wait for the same result
...
R20 ───────┘The outcome becomes:
20 requests = 1 computation + 19 waitersCaching and single-flight complement each other well.
Caching avoids repeated work across requests over time, while single-flight avoids duplicate work when multiple requests miss the cache at the same time.
2. Pre-computation for Fixed and Dynamic Time Ranges
Caching still depends on the first request.
If some results are highly predictable, it may be better to compute them in advance.
For example, a dashboard may provide predefined ranges such as:
Last 24 hours
Last 7 days
Last month
Last 3 months
Last year
Instead of:
User request
↓
Compute
↓
Returnthe system can run a background process:
Scheduler
↓
Compute
↓
Store result
↓
User request
↓
Read pre-computed resultThis works especially well for expensive reports with fixed time ranges.
Dynamic ranges
The harder case is when users can freely select a range using two datetime pickers.
For example:
2026-01-17
→
2026-09-09
Pre-computing every possible combination is not realistic.
A better approach is to divide the timeline into reusable aggregated building blocks, such as daily, weekly, or monthly blocks.
For example:
partial January
+
February
+
March
+
April
+
May
+
June
+
July
+
August
+
partial SeptemberInstead of recomputing the entire range, the system can:
compute the left partial range
+
reuse complete monthly blocks
+
compute the right partial rangeThis can significantly reduce repeated computation.
However, not every metric can be merged in the same way.
Metrics such as:
SUM(revenue)
SUM(order_count)are naturally additive.
But metrics such as:
AVG
COUNT DISTINCT
median
p95
conversion raterequire more care.
For example, with an average, storing only the average value of each block is usually insufficient.
Instead, the block should preserve values such as:
sum
countso the final result can be reconstructed as:
globalAverage =
totalSum / totalCountThis leads to an important question when designing reusable aggregates:
Which metrics are actually composable across smaller blocks?
3. Don’t Let the Slowest Workload Block the Entire Dashboard
A dashboard often contains several independent computations.
For example:
Revenue 1.5s
Orders 2.0s
Users 1.0s
Heatmap 5.0sIf they run sequentially:
1.5 + 2 + 1 + 5 ≈ 9.5sIf they are independent, they may be executed in parallel.
In TypeScript, for example:
const [
revenue,
orders,
users,
heatmap,
] = await Promise.all([
getRevenue(),
getOrders(),
getUsers(),
getHeatmap(),
]);The total time may then move closer to the slowest workload: ≈ 5s
However, concurrency does not mean “run everything at once.”
Turning 100 sequential database queries into 100 concurrent queries can create a different problem:
connection pool exhaustion
CPU spikes
database overloadThe goal is not maximum concurrency.
The goal is controlled parallelism for workloads that are truly independent.
There is another related issue.
Suppose the dashboard contains:
KPI 600ms
Revenue 1.2s
Product 2s
Heatmap 7sIf everything is returned through:
GET /dashboardThe user may have to wait nearly 7 seconds before seeing anything.
An alternative is to split the dashboard into logical API groups:
GET /dashboard/overview
GET /dashboard/revenue
GET /dashboard/products
GET /dashboard/heatmap
The UI can then render progressively:
0.6s → KPI
1.2s → Revenue
2.0s → Product
7.0s → HeatmapThis does not necessarily reduce the heatmap’s actual computation time.
But it can significantly improve perceived latency.
That distinction matters:
Performance optimization is not always about making every computation faster. Sometimes it is about preventing the slowest computation from blocking the entire user experience.
4. When the Bottleneck Is No Longer Backend Computation
Sometimes the backend is already fast enough, but the dashboard still feels slow.
For example:
Server compute: 300ms
Response transfer: 2.5s
Browser processing: 1s
In this case, shaving another 50ms off the database query will not make much difference.
The entire request lifecycle should be considered:
Database
↓
Backend computation
↓
Serialization
↓
Network
↓
Browser
↓
RenderingCache hierarchy
Caching can exist at several layers:
Client memory
↓
Browser / HTTP cache
↓
API cache
↓
Pre-computed result
↓
Source computation
The closer a request can be resolved to the client, the more expensive downstream work can be avoided.
Not every system needs every layer.
But cache design is often easier to reason about when viewed as a hierarchy rather than as a single Redis layer.
Client-side server-state caching
Libraries such as:
TanStack Query
SWR
Apollo Client
can help reuse server data in the browser.
For example:
Dashboard
→ Detail page
→ Back to DashboardWithout client-side caching, the application may fetch everything again.
With server-state caching, it can reuse existing data and optionally revalidate it in the background.
This does not make the first backend request faster.
It mainly reduces unnecessary repeated requests.
Payload optimization
The API should also avoid sending more data than the UI actually needs.
If a chart only needs:
{
"date": "2026-09",
"revenue": 5000
}there is little value in also returning large nested relations, metadata, audit logs, and unused fields.
Oversized responses increase:
serialization cost
network transfer time
memory usage
browser parsing costThe same idea applies to data granularity.
If the chart can meaningfully display only a limited number of points, returning millions of raw events is usually unnecessary.
A better flow may be:
raw events
↓
hourly/daily buckets
↓
chartFor large tables, pagination and lazy loading are usually better than sending the full dataset in one response.
5. When the Workload Is Too Heavy for a Synchronous Request
Some operations are simply not a good fit for synchronous HTTP.
Consider an export job that takes 30 seconds:
Export two years of report data
→ 30 secondsInstead of:
Client
↓
wait 30s
↓
downloadthe operation can be moved to a background job:
POST /exports
↓
202 Accepted
↓
return jobIdThe worker can then:
Aggregate data
↓
Generate file
↓
Store file
↓
Notify userThe client can receive updates through:
Polling
WebSocket
SSE
System notification
EmailIn this case, optimization does not necessarily mean:
30s → 3sIt may simply mean:
Don’t force the user to keep a synchronous HTTP request open for 30 seconds.
6. Measurement and Failure Handling
Performance optimization should not be based only on intuition.
Instead of saying:
This API feels slow.
it is better to measure:
p50
p95
p99
cache hit rate
response size
database time
aggregation time
serialization time
For example:
Before
p50: 1.8s
p95: 7.4s
p99: 11.2s
response size: 2.8 MB
After optimization:
After
p50: 250ms
p95: 1.8s
p99: 6.5s
cache hit rate: 82%
response size: 620 KB
This tells a more useful story.
The median request may now be much faster, while p99 is still relatively high.
That could indicate that cache misses or specific workloads remain expensive.
Measurement helps identify what actually improved and what still needs work.
Failure handling
The happy path is only part of the system.
Suppose Redis becomes unavailable.
A simple fallback may be:
Redis error
↓
compute from source
↓
return
That looks reasonable until 100 requests do the same thing at once:
100 requests
↓
100 expensive computations
↓
database overload
A cache failure can therefore cascade into a larger failure.
This is where mechanisms such as the following may become useful:
single-flight
rate limiting
stale cache
circuit breaker
Failure handling is not just about catching exceptions.
It is about controlling how failures propagate through the system.
The same principle applies to pre-computation.
Instead of:
delete old result
↓
compute new result
↓
computation fails
↓
no data
a safer approach is:
Current result: v42
↓
Compute v43
↓
Success?
├─ yes → switch to v43
└─ no → keep serving v42
The old result should remain available until the new result has been successfully generated.
For background jobs, the lifecycle should also be explicit:
PENDING
↓
PROCESSING
↓
COMPLETED / FAILED
Retries should be designed together with idempotency so that the same logical job does not accidentally create duplicate files, duplicate notifications, or other repeated side effects.
7. When Should You Move to a Larger Architectural Change?
There are many stronger solutions for analytical workloads:
Materialized Views
PostgreSQL aggregate tables
TimescaleDB
ClickHouse
Elasticsearch
BigQuery
Data Warehouse
These technologies can be excellent choices.
But a powerful technology is not automatically the right solution for every task.
I find it useful to separate optimizations into two groups.
Tactical optimizations
cache
single-flight
pre-computation
parallel computation
API splitting
payload optimization
frontend caching
background jobsTypical advantages:
reuse existing logic
lower implementation cost
lower migration risk
faster deliveryArchitectural optimizations
Materialized Views
Aggregate tables
ClickHouse
TimescaleDB
Data Warehouse
BigQueryThese can provide better long-term scalability for analytical workloads, but they also introduce additional costs:
data migration
data pipelines
synchronization
infrastructure
monitoring
operational complexitySo the real question should not simply be:
Which technology is faster?
A better question is:
What level of architectural complexity is justified by the current bottleneck and system constraints?
Conclusion
When dealing with a slow dashboard, I usually think through the problem in roughly this order:
Are we recomputing the same result?
↓
Can we cache or pre-compute it?
↓
Can dynamic ranges be built from reusable aggregates?
↓
Are independent computations running sequentially?
↓
Is one slow workload blocking the entire UI?
↓
Are we transferring more data than the UI needs?
↓
Should this workload even be synchronous?
↓
Are we measuring the optimization?
↓
What happens when cache, workers, or pre-computation fail?In my case, the main constraint was that I could not significantly change how the data was originally computed.
So instead of immediately rewriting the data layer, the more practical approach was to:
Reuse before recomputing
Pre-compute predictable workloads
Compose dynamic ranges when possible
Parallelize independent work
Avoid blocking the entire UI on the slowest workload
Reduce unnecessary data transfer
Move long-running work to background jobs
Measure before and after optimization
Design for failure paths, not only happy paths
A good optimization is not necessarily the most sophisticated one.
It is the one that addresses the actual bottleneck while keeping implementation cost and architectural complexity appropriate for the system.
