Client-Side or Server-Side? An Architecture Note on Plotly Dashboards Over Large Data
Plotly ships every figure as JSON to the browser, so the real architecture decision for large-data dashboards is where the rendering boundary sits. This note covers the smallest server-side aggregation design, trust boundaries, caching, operational checks, and failure modes.
04 Dec 2025, 11:05 UTC

Every Plotly figure is serialized to JSON and shipped to the browser. That single fact, not chart aesthetics or framework choice, is the dominant constraint when you build a dashboard over a large or sensitive dataset. The useful takeaway: decide your rendering boundary first — what data is allowed to leave the server — and let that decision drive everything else, from callback design to caching.
Requirements that actually matter
Before picking a design, pin down three requirements, because they determine where the rendering boundary sits:
- Data volume per view. A browser tab starts to struggle somewhere in the low millions of points per figure, depending on trace type and client hardware. If a single chart needs more raw rows than that, client-side rendering of raw data is off the table.
- Data sensitivity. Anything in the figure JSON is readable in browser dev tools, including points you never draw. "Hidden" traces are not a confidentiality mechanism.
- Concurrent users. Each Dash callback executes Python on the server per interaction. Ten users zooming simultaneously is ten concurrent queries unless you cache.
Assumptions in this note: Dash 2.x on Python 3.10+, pandas for aggregation, and a relational or columnar store behind the app. Callback APIs are version-sensitive, so verify against your installed dash version before copying patterns.
The smallest suitable design
For most "large dataset" dashboards, the smallest design that works is: a Dash app whose callbacks query a database or aggregation layer and return only downsampled series. Raw records never leave the server.
Concretely, a time-series chart callback looks like this in shape:
from dash import Dash, Input, Output, dcc, graph # placeholder imports
@app.callback(
Output("ts-chart", "figure"),
Input("date-range", "start_date"),
Input("date-range", "end_date"),
)
def update_chart(start, end):
# Runs on the server. Query pre-aggregated buckets, not raw rows.
df = query_daily_buckets(start, end) # SQL GROUP BY date_trunc('day', ts)
fig = px.line(df, x="day", y="value_sum")
return figThe key property is that query_daily_buckets returns hundreds of rows, not millions. The browser receives a small figure JSON; the raw table stays behind the trust boundary.
For zoom-to-detail, add a second callback keyed on the chart's relayoutData: when the user zooms into a narrow range, re-query that range at finer granularity (hourly instead of daily). This keeps every payload small while preserving the feel of full-resolution data.
Trust and data boundaries
Draw the boundary at the figure JSON. Rules that follow:
- Never put row-level confidential data into a trace, even with
visible: "legendonly"or off-screen axes. It is in the payload. - WebGL trace types such as
Scatterglraise the client-side point limit substantially, but they change nothing about exposure — the data still ships to the browser. - Aggregation must happen in a layer you control: SQL, pandas on the server, or a pre-aggregated OLAP store. Do not aggregate in the browser and call it protection.
Operational checks
Three checks map directly to the design's claims:
- Payload inspection. Open browser dev tools, load the dashboard, and inspect the callback response. Confirm it contains only aggregated series. This is a five-minute check that catches the most expensive mistake.
- Concurrency profile. Simulate concurrent users hitting a callback (a simple load tool against the Dash endpoint works) and measure server CPU and latency. If latency degrades linearly with users, add caching.
- Zoom correctness. Zoom into a known range and verify the finer-granularity re-query returns the expected values within your time budget (a few seconds is a common target).
Caching and the callback bottleneck
Stateless callbacks plus an external cache is the standard mitigation for the per-interaction Python execution cost. Flask-Caching with a Redis backend is a common pairing:
from flask_caching import Cache
cache = Cache(app.server, config={
"CACHE_TYPE": "RedisCache",
"CACHE_REDIS_URL": "redis://localhost:6379/0", # placeholder
"CACHE_DEFAULT_TIMEOUT": 300,
})
@cache.memoize()
def query_daily_buckets(start, end):
... # expensive query hereRun this where the Dash app runs; it needs network access to Redis and no special filesystem permissions. The risk to note: memoize keys on arguments, so identical ranges share results — which is exactly what you want for concurrent users viewing the same window.
Failure modes
- Browser memory exhaustion. A figure with too many points can freeze or crash the tab. Check: payload size in dev tools; fix: stricter server-side downsampling.
- Callback timeouts. A slow query blocks the callback and the UI spins. Check: time the query independently of Dash; fix: pre-aggregation, indexes, or a timeout with a user-facing error.
- Stale caches. After upstream data changes, cached aggregates are wrong. Check: compare a cached result against a fresh query; fix: shorter TTLs or explicit cache invalidation on load events.
Conditions that would change the design
This architecture is not universal. Revisit it when:
- Data is small and non-sensitive. Pure client-side Plotly.js with a static figure needs no backend at all — simpler to host and operate.
- Interactivity must scale to many users. When caching no longer absorbs the load, a pre-aggregated OLAP store (a columnar database with rollups) behind the callbacks becomes justified.
- Row-level detail must be explorable. Then the question shifts from rendering to access control: authenticated queries server-side, with the boundary enforced by authorization, not aggregation.
The decision is cheap to re-evaluate because the boundary is explicit: whatever crosses into figure JSON is public to the client. Keep that line visible in code review and the rest of the design follows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.