Skip to content

Commit c06fbe7

Browse files
authored
Merge pull request #3925 from datenzauberai/compress_payload
Add options to compress callback request payload
2 parents a8bbf98 + 9db36ac commit c06fbe7

10 files changed

Lines changed: 252 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66

77
### Added
88
- [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release.
9+
- [#3925](https://github.com/plotly/dash/pull/3925) Add optional callback request payload compression for server-side callbacks via `compress_payload` and `compress_threshold` callback parameters (default threshold: 5,000 bytes). When enabled and the request body exceeds the threshold, the renderer sends gzip-compressed binary payloads with `Content-Encoding: gzip`, and Dash transparently decompresses on the server (Flask, FastAPI, and Quart). This can significantly reduce callback roundtrip times for large client-to-server payloads. Fixes [#3924](https://github.com/plotly/dash/issues/3924).
910

1011
### Removed
1112
- [#3646](https://github.com/plotly/dash/pull/3646) Remove React 16 support (`16.14.0` is no longer an accepted value for `REACT_VERSION` / `_set_react_version`).

‎dash/_callback.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def callback(
9090
persistent: Optional[bool] = False,
9191
mcp_enabled: Optional[bool] = None,
9292
mcp_expose_docstring: Optional[bool] = None,
93+
compress_payload: bool = False,
94+
compress_threshold: int = 5_000,
9395
**_kwargs,
9496
) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]:
9597
"""
@@ -188,6 +190,15 @@ def callback(
188190
If True, this callback will not show the "Updating..." title while
189191
running. Useful for persistent WebSocket callbacks that stay active
190192
for long periods without requiring a loading indicator.
193+
:param compress_payload:
194+
If True, the callback request payload will be compressed using gzip
195+
compression before being sent to the server. This can significantly
196+
reduce network transmission size for large payloads.
197+
Defaults to False.
198+
:param compress_threshold:
199+
The size threshold in bytes above which the payload will be compressed
200+
when `compress_payload` is True. Set to 0 to always compress regardless
201+
of size. Defaults to 5,000 bytes (5 kB).
191202
"""
192203

193204
background_spec: Any = None
@@ -249,6 +260,8 @@ def callback(
249260
persistent=persistent,
250261
mcp_enabled=mcp_enabled,
251262
mcp_expose_docstring=mcp_expose_docstring,
263+
compress_payload=compress_payload,
264+
compress_threshold=compress_threshold,
252265
)
253266

254267
return cast(
@@ -304,6 +317,8 @@ def insert_callback(
304317
persistent=False,
305318
mcp_enabled=None,
306319
mcp_expose_docstring=None,
320+
compress_payload: bool = False,
321+
compress_threshold: int = 5_000,
307322
) -> str:
308323
if prevent_initial_call is None:
309324
prevent_initial_call = config_prevent_initial_callbacks
@@ -331,6 +346,8 @@ def insert_callback(
331346
"hidden": hidden,
332347
"websocket": websocket,
333348
"persistent": persistent,
349+
"compress_payload": compress_payload,
350+
"compress_threshold": compress_threshold,
334351
}
335352
if running:
336353
callback_spec["running"] = running
@@ -349,6 +366,8 @@ def insert_callback(
349366
"websocket": websocket,
350367
"mcp_enabled": mcp_enabled,
351368
"mcp_expose_docstring": mcp_expose_docstring,
369+
"compress_payload": compress_payload,
370+
"compress_threshold": compress_threshold,
352371
}
353372
callback_list.append(callback_spec)
354373

@@ -773,6 +792,8 @@ def register_callback(
773792
persistent=_kwargs.get("persistent", False),
774793
mcp_enabled=_kwargs.get("mcp_enabled", None),
775794
mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"),
795+
compress_payload=_kwargs.get("compress_payload", False),
796+
compress_threshold=_kwargs.get("compress_threshold", 5_000),
776797
)
777798

778799
# pylint: disable=too-many-locals

‎dash/_compression.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Utilities for decompressing callback payloads.
3+
4+
Payload compression reduces network traffic for large callback requests,
5+
particularly useful for callbacks with large data payloads.
6+
"""
7+
8+
import json
9+
import os
10+
import zlib
11+
from typing import Any
12+
13+
# Maximum decompressed payload size is 64 MB by default
14+
MAX_PAYLOAD_SIZE = int(os.getenv("DASH_MAX_PAYLOAD_SIZE_MB", "64")) * 1024 * 1024
15+
16+
17+
def decompress_payload(data: bytes, max_size: int = MAX_PAYLOAD_SIZE) -> Any:
18+
"""
19+
Decompress a gzip-compressed callback request body.
20+
21+
The data is expected to be the raw bytes of a gzip-compressed UTF-8 JSON payload.
22+
23+
Args:
24+
data: The raw compressed request body bytes.
25+
max_size: The maximum allowed size of the decompressed payload in bytes.
26+
27+
Returns:
28+
The decompressed and parsed callback request dictionary.
29+
30+
Raises:
31+
ValueError: If the data cannot be decompressed or parsed.
32+
"""
33+
try:
34+
decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
35+
decompressed = decompressor.decompress(data, max_size + 1)
36+
37+
if len(decompressed) > max_size:
38+
raise ValueError("Decompressed callback payload is too large.")
39+
40+
if not decompressor.eof:
41+
raise ValueError("Incomplete gzip callback payload.")
42+
43+
if decompressor.unused_data:
44+
raise ValueError("Unexpected data after gzip callback payload.")
45+
46+
return json.loads(decompressed.decode("utf-8"))
47+
except Exception as e:
48+
raise ValueError("Failed to decompress callback payload.") from e

‎dash/backends/_fastapi.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from dash.fingerprint import check_fingerprint
3838
from dash import _validate, get_app
3939
from dash.exceptions import PreventUpdate
40+
from dash._compression import decompress_payload
4041
from .base_server import (
4142
BaseDashServer,
4243
RequestAdapter,
@@ -328,7 +329,8 @@ def setup_catchall(self, dash_app: Dash):
328329
and passed through the middleware, which is necessary for features like authentication
329330
and timing to work correctly on all routes. FastAPI will match this catch-all route
330331
for any path that isn't matched by a more specific route, allowing the middleware to
331-
process the request and then return the appropriate response (e.g., 404 if no Dash route matches)."""
332+
process the request and then return the appropriate response (e.g., 404 if no Dash route matches).
333+
"""
332334

333335
def _setup_catchall(self):
334336
try:
@@ -549,7 +551,10 @@ def add_redirect_rule(self, app, fullname, path):
549551
def serve_callback(self, dash_app: Dash):
550552
async def _dispatch(request: Request): # pylint: disable=unused-argument
551553
# pylint: disable=protected-access
552-
body = self.request_adapter().get_json()
554+
if "gzip" in request.headers.get("content-encoding", ""):
555+
body = decompress_payload(await self.request_adapter()._request.body())
556+
else:
557+
body = self.request_adapter().get_json()
553558
cb_ctx = dash_app._initialize_context(
554559
body
555560
) # pylint: disable=protected-access

‎dash/backends/_flask.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
from dash import _validate
3030
from dash.exceptions import PreventUpdate, InvalidResourceError
3131
from dash._callback import _invoke_callback, _async_invoke_callback
32+
from dash._compression import decompress_payload
3233
from dash._utils import parse_version
3334
from .base_server import BaseDashServer, RequestAdapter, ResponseAdapter
3435

35-
3636
if TYPE_CHECKING: # pragma: no cover - typing only
3737
from dash import Dash
3838

@@ -252,7 +252,10 @@ def add_redirect_rule(self, app, fullname, path):
252252
# pylint: disable=unused-argument
253253
def serve_callback(self, dash_app: Dash):
254254
def _dispatch():
255-
body = request.get_json()
255+
if "gzip" in request.headers.get("Content-Encoding", ""):
256+
body = decompress_payload(request.data)
257+
else:
258+
body = request.get_json()
256259
# pylint: disable=protected-access
257260
cb_ctx = dash_app._initialize_context(body)
258261
func = dash_app._prepare_callback(cb_ctx, body)
@@ -271,7 +274,10 @@ def _dispatch():
271274
return cb_ctx.dash_response.set_response(data=response_data)
272275

273276
async def _dispatch_async():
274-
body = request.get_json()
277+
if "gzip" in request.headers.get("Content-Encoding", ""):
278+
body = decompress_payload(request.data)
279+
else:
280+
body = request.get_json()
275281
# pylint: disable=protected-access
276282
cb_ctx = dash_app._initialize_context(body)
277283
func = dash_app._prepare_callback(cb_ctx, body)

‎dash/backends/_quart.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from dash.fingerprint import check_fingerprint
4343
from dash._utils import parse_version
4444
from dash import _validate
45+
from dash._compression import decompress_payload
4546
from .base_server import (
4647
BaseDashServer,
4748
RequestAdapter,
@@ -385,7 +386,10 @@ def add_redirect_rule(self, app, fullname, path):
385386
def serve_callback(self, dash_app: Dash): # type: ignore[override] # Quart always async
386387
async def _dispatch():
387388
adapter = QuartRequestAdapter()
388-
body = await adapter.get_json()
389+
if "gzip" in adapter.request.headers.get("Content-Encoding", ""):
390+
body = decompress_payload(await adapter.request.get_data())
391+
else:
392+
body = await adapter.get_json()
389393
# pylint: disable=protected-access
390394
cb_ctx = dash_app._initialize_context(body)
391395
# pylint: disable=protected-access

‎dash/dash-renderer/src/actions/callbacks.ts‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,9 @@ function handleServerside(
511511
background: BackgroundCallbackInfo | undefined,
512512
additionalArgs: [string, string, boolean?][] | undefined,
513513
getState: any,
514-
running: any
514+
running: any,
515+
compressPayload?: boolean,
516+
compressThreshold?: number
515517
): Promise<CallbackResponse> {
516518
if (hooks.request_pre) {
517519
hooks.request_pre(payload);
@@ -530,7 +532,7 @@ function handleServerside(
530532
runningOff = running.runningOff;
531533
}
532534

533-
const fetchCallback = () => {
535+
const fetchCallback = async () => {
534536
const headers = getCSRFHeader(config) as any;
535537
let url = `${urlBase(config)}_dash-update-component`;
536538
let newBody = body;
@@ -567,12 +569,36 @@ function handleServerside(
567569
moreArgs = moreArgs.filter(([_, __, single]) => !single);
568570
}
569571

572+
let fetchBody: BodyInit = newBody;
573+
574+
// Compress payload if enabled and size threshold is met
575+
if (
576+
compressPayload &&
577+
compressThreshold !== undefined &&
578+
newBody.length > compressThreshold
579+
) {
580+
try {
581+
const stream = new Blob([newBody])
582+
.stream()
583+
.pipeThrough(new CompressionStream('gzip'));
584+
fetchBody = await new Response(stream).blob();
585+
headers['Content-Encoding'] = 'gzip';
586+
} catch (error) {
587+
// Fall through to send uncompressed
588+
// eslint-disable-next-line no-console
589+
console.warn(
590+
'Sending uncompressed payload, because compressing failed:',
591+
error
592+
);
593+
}
594+
}
595+
570596
return fetch(
571597
url,
572598
mergeDeepRight(config.fetch, {
573599
method: 'POST',
574600
headers,
575-
body: newBody
601+
body: fetchBody
576602
})
577603
);
578604
};
@@ -1100,7 +1126,9 @@ export function executeCallback(
11001126
? additionalArgs
11011127
: undefined,
11021128
getState,
1103-
cb.callback.running
1129+
cb.callback.running,
1130+
cb.callback.compress_payload,
1131+
cb.callback.compress_threshold
11041132
);
11051133
}
11061134

‎dash/dash-renderer/src/types/callbacks.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ export interface ICallbackDefinition {
1919
no_output?: boolean;
2020
websocket?: boolean;
2121
persistent?: boolean;
22+
compress_payload?: boolean;
23+
compress_threshold?: number;
2224
}
2325

2426
export interface ICallbackProperty {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Integration tests for callback payload compression.
3+
"""
4+
5+
import pytest
6+
from dash import Dash, html, dcc, Input, Output, State
7+
8+
9+
@pytest.mark.parametrize(
10+
"backend,dash_duo_fixture",
11+
[("flask", "dash_duo"), ("quart", "dash_duo_mp"), ("fastapi", "dash_duo")],
12+
)
13+
@pytest.mark.parametrize("payload_size", [1, 500_000])
14+
@pytest.mark.parametrize("compress_threshold", [0, 500_000])
15+
def test_cbcomp01_compress_request_payload(
16+
request, dash_duo_fixture, backend, payload_size, compress_threshold
17+
):
18+
"""Test that the client sends a compressed body when appropriate."""
19+
if backend == "quart":
20+
pytest.importorskip(
21+
"quart", reason="Quart extra dependencies are not installed"
22+
)
23+
pytest.importorskip("hypercorn", reason="hypercorn is not installed")
24+
elif backend == "fastapi":
25+
pytest.importorskip(
26+
"fastapi", reason="fastapi extra dependencies are not installed"
27+
)
28+
29+
app = Dash(__name__, backend=backend)
30+
31+
@app.backend.before_request
32+
def capture_compression():
33+
# intercept the request to /_dash-update-component and record whether the payload was compressed
34+
req = app.backend.request_adapter()
35+
if req.path == "/_dash-update-component":
36+
if "gzip" in req.headers.get("Content-Encoding", ""):
37+
req.context.compressed_payload_size = int(
38+
req.headers.get("content-length", 0)
39+
)
40+
else:
41+
req.context.compressed_payload_size = None
42+
43+
@app.callback(
44+
Output("data_size", "children"),
45+
Output("data_compressed", "children"),
46+
Output("data_compressed_size", "children"),
47+
Input("btn", "n_clicks"),
48+
State("store", "data"),
49+
compress_payload=True,
50+
compress_threshold=compress_threshold,
51+
prevent_initial_call=True,
52+
)
53+
def on_click(n, data):
54+
# log the size of the data and whether it was compressed
55+
compressed_payload_size = (
56+
app.backend.request_adapter().context.compressed_payload_size
57+
)
58+
return (
59+
len(data),
60+
repr(compressed_payload_size is not None),
61+
compressed_payload_size,
62+
)
63+
64+
app.layout = html.Div(
65+
[
66+
html.Button("Click", id="btn"),
67+
html.Div(id="data_size"),
68+
html.Div(id="data_compressed"),
69+
html.Div(id="data_compressed_size"),
70+
dcc.Store(id="store", data="x" * payload_size),
71+
]
72+
)
73+
74+
dash_duo = request.getfixturevalue(dash_duo_fixture)
75+
dash_duo.start_server(app)
76+
dash_duo.find_element("#btn").click()
77+
# assert that the data size matches the expected payload size
78+
dash_duo.wait_for_text_to_equal("#data_size", f"{payload_size}")
79+
# assert that the data was compressed if the payload size is greater than or equal to the compression threshold
80+
dash_duo.wait_for_text_to_equal(
81+
"#data_compressed", repr(payload_size >= compress_threshold)
82+
)

0 commit comments

Comments
 (0)