From 4302682fc193fd1867f60707f1eaca5f7fe2385e Mon Sep 17 00:00:00 2001 From: datenzauberai Date: Wed, 29 Jul 2026 18:32:15 +0200 Subject: [PATCH 1/5] Added options to compress callback request body --- dash/_callback.py | 21 +++++++ dash/_compression.py | 54 ++++++++++++++++ dash/backends/_fastapi.py | 3 + dash/backends/_flask.py | 6 +- dash/backends/_quart.py | 3 + dash/dash-renderer/package-lock.json | 12 ++++ dash/dash-renderer/package.json | 1 + dash/dash-renderer/src/actions/callbacks.ts | 29 ++++++++- dash/dash-renderer/src/types/callbacks.ts | 2 + .../callbacks/test_compressed_callback.py | 62 +++++++++++++++++++ tests/unit/test_payload_compression.py | 44 +++++++++++++ 11 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 dash/_compression.py create mode 100644 tests/integration/callbacks/test_compressed_callback.py create mode 100644 tests/unit/test_payload_compression.py diff --git a/dash/_callback.py b/dash/_callback.py index 61908942ef..868c1eb842 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -90,6 +90,8 @@ def callback( persistent: Optional[bool] = False, mcp_enabled: Optional[bool] = None, mcp_expose_docstring: Optional[bool] = None, + compress_payload: bool = False, + compress_threshold: int = 500_000, **_kwargs, ) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]: """ @@ -188,6 +190,15 @@ def callback( If True, this callback will not show the "Updating..." title while running. Useful for persistent WebSocket callbacks that stay active for long periods without requiring a loading indicator. + :param compress_payload: + If True, the callback request payload will be compressed using gzip + compression before being sent to the server. This can significantly + reduce network transmission size for large payloads. + Defaults to False. + :param compress_threshold: + The size threshold in bytes above which the payload will be compressed + when `compress_payload` is True. Set to 0 to always compress regardless + of size. Defaults to 500,000 bytes (500 KB). """ background_spec: Any = None @@ -249,6 +260,8 @@ def callback( persistent=persistent, mcp_enabled=mcp_enabled, mcp_expose_docstring=mcp_expose_docstring, + compress_payload=compress_payload, + compress_threshold=compress_threshold, ) return cast( @@ -304,6 +317,8 @@ def insert_callback( persistent=False, mcp_enabled=None, mcp_expose_docstring=None, + compress_payload: bool = False, + compress_threshold: int = 500_000, ) -> str: if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -331,6 +346,8 @@ def insert_callback( "hidden": hidden, "websocket": websocket, "persistent": persistent, + "compress_payload": compress_payload, + "compress_threshold": compress_threshold, } if running: callback_spec["running"] = running @@ -349,6 +366,8 @@ def insert_callback( "websocket": websocket, "mcp_enabled": mcp_enabled, "mcp_expose_docstring": mcp_expose_docstring, + "compress_payload": compress_payload, + "compress_threshold": compress_threshold, } callback_list.append(callback_spec) @@ -773,6 +792,8 @@ def register_callback( persistent=_kwargs.get("persistent", False), mcp_enabled=_kwargs.get("mcp_enabled", None), mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"), + compress_payload=_kwargs.get("compress_payload", False), + compress_threshold=_kwargs.get("compress_threshold", 500_000), ) # pylint: disable=too-many-locals diff --git a/dash/_compression.py b/dash/_compression.py new file mode 100644 index 0000000000..8575bdb77d --- /dev/null +++ b/dash/_compression.py @@ -0,0 +1,54 @@ +""" +Utilities for decompressing callback payloads. + +Payload compression reduces network traffic for large callback requests, +particularly useful for callbacks with large data payloads. +""" + +import base64 +import json +import zlib +from typing import Any + +COMPRESSED_PAYLOAD_FIELD = "__compressed_payload__" + + +def decompress_payload(request_body: dict[str, Any]) -> dict[str, Any]: + """ + Decompress a callback request body if it was compressed. + + If the request body contains a ``__compressed_payload__`` field, + this function decompresses the gzipped payload and returns the parsed JSON dictionary. + + If the request body is not compressed, it is returned as-is. + + Args: + request_body: The parsed request body as a dictionary. + + Returns: + The decompressed and parsed callback request dictionary. + + Raises: + ValueError: If the compressed data is invalid or cannot be decompressed. + """ + if COMPRESSED_PAYLOAD_FIELD not in request_body: + # return the original request body if it does not contain the compressed payload marker + return request_body + + # Extract and decode the compressed data + compressed_data_b64_encoded = request_body.get(COMPRESSED_PAYLOAD_FIELD) + if not compressed_data_b64_encoded: + raise ValueError("Failed to decompress callback payload.") + + try: + compressed_data_binary = base64.b64decode(compressed_data_b64_encoded) + # fflate's gzipSync produces gzip format, so we need to signal to zlib that it's gzip by using 16 + MAX_WBITS + decompressed_data_binary = zlib.decompress(compressed_data_binary, zlib.MAX_WBITS | 16) + decompressed_body = json.loads(decompressed_data_binary.decode("utf-8")) + + return decompressed_body + + except Exception as e: + raise ValueError( + "Failed to decompress callback payload." + ) from e diff --git a/dash/backends/_fastapi.py b/dash/backends/_fastapi.py index 6a23c4873b..8620dde4cd 100644 --- a/dash/backends/_fastapi.py +++ b/dash/backends/_fastapi.py @@ -37,6 +37,7 @@ from dash.fingerprint import check_fingerprint from dash import _validate, get_app from dash.exceptions import PreventUpdate +from dash._compression import decompress_payload from .base_server import ( BaseDashServer, RequestAdapter, @@ -550,6 +551,8 @@ def serve_callback(self, dash_app: Dash): async def _dispatch(request: Request): # pylint: disable=unused-argument # pylint: disable=protected-access body = self.request_adapter().get_json() + # Decompress payload if it was compressed + body = decompress_payload(body) cb_ctx = dash_app._initialize_context( body ) # pylint: disable=protected-access diff --git a/dash/backends/_flask.py b/dash/backends/_flask.py index 43f24634a2..7d2bfd3ec6 100644 --- a/dash/backends/_flask.py +++ b/dash/backends/_flask.py @@ -29,6 +29,7 @@ from dash import _validate from dash.exceptions import PreventUpdate, InvalidResourceError from dash._callback import _invoke_callback, _async_invoke_callback +from dash._compression import decompress_payload from dash._utils import parse_version from .base_server import BaseDashServer, RequestAdapter, ResponseAdapter @@ -253,6 +254,8 @@ def add_redirect_rule(self, app, fullname, path): def serve_callback(self, dash_app: Dash): def _dispatch(): body = request.get_json() + # Decompress payload if it was compressed + body = decompress_payload(body) # pylint: disable=protected-access cb_ctx = dash_app._initialize_context(body) func = dash_app._prepare_callback(cb_ctx, body) @@ -272,7 +275,8 @@ def _dispatch(): async def _dispatch_async(): body = request.get_json() - # pylint: disable=protected-access + # Decompress payload if it was compressed + body = decompress_payload(body) cb_ctx = dash_app._initialize_context(body) func = dash_app._prepare_callback(cb_ctx, body) args = dash_app._inputs_to_vals(cb_ctx.inputs_list + cb_ctx.states_list) diff --git a/dash/backends/_quart.py b/dash/backends/_quart.py index 9487cac7d8..83fe7125c8 100644 --- a/dash/backends/_quart.py +++ b/dash/backends/_quart.py @@ -42,6 +42,7 @@ from dash.fingerprint import check_fingerprint from dash._utils import parse_version from dash import _validate +from dash._compression import decompress_payload from .base_server import ( BaseDashServer, RequestAdapter, @@ -386,6 +387,8 @@ def serve_callback(self, dash_app: Dash): # type: ignore[override] # Quart alw async def _dispatch(): adapter = QuartRequestAdapter() body = await adapter.get_json() + # Decompress payload if it was compressed + body = decompress_payload(body) # pylint: disable=protected-access cb_ctx = dash_app._initialize_context(body) # pylint: disable=protected-access diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index 65a5b82d77..2872d92832 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -17,6 +17,7 @@ "cytoscape-fcose": "^2.2.0", "dependency-graph": "^0.11.0", "fast-isnumeric": "^1.1.4", + "fflate": "^0.8.0", "node-polyfill-webpack-plugin": "^2.0.1", "prop-types": "15.8.1", "ramda": "^0.30.1", @@ -5756,6 +5757,12 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "dev": true }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -15557,6 +15564,11 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "dev": true }, + "fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index 141352295b..4e7eb79df4 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -29,6 +29,7 @@ "cytoscape-fcose": "^2.2.0", "dependency-graph": "^0.11.0", "fast-isnumeric": "^1.1.4", + "fflate": "^0.8.0", "node-polyfill-webpack-plugin": "^2.0.1", "prop-types": "15.8.1", "ramda": "^0.30.1", diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 6dec01996c..5b6ca1c654 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -14,6 +14,8 @@ import { assocPath } from 'ramda'; +import {gzipSync} from 'fflate'; + import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants'; import {MAX_AUTH_RETRIES} from './constants'; import { @@ -468,7 +470,9 @@ function handleServerside( background: BackgroundCallbackInfo | undefined, additionalArgs: [string, string, boolean?][] | undefined, getState: any, - running: any + running: any, + compressPayload?: boolean, + compressThreshold?: number ): Promise { if (hooks.request_pre) { hooks.request_pre(payload); @@ -524,6 +528,25 @@ function handleServerside( moreArgs = moreArgs.filter(([_, __, single]) => !single); } + // Compress payload if enabled and size threshold is met + if ( + compressPayload && + compressThreshold !== undefined && + newBody.length > compressThreshold + ) { + try { + const compressed = gzipSync(new TextEncoder().encode(newBody)); + const compressedB64 = btoa( + Array.from(compressed, b => String.fromCharCode(b)).join('') + ); + newBody = JSON.stringify({ + __compressed_payload__: compressedB64 + }); + } catch (error) { + // Fall through to send uncompressed + } + } + return fetch( url, mergeDeepRight(config.fetch, { @@ -1069,7 +1092,9 @@ export function executeCallback( ? additionalArgs : undefined, getState, - cb.callback.running + cb.callback.running, + cb.callback.compress_payload, + cb.callback.compress_threshold ); } diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index 5f963463d2..7b9d7ebe93 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -17,6 +17,8 @@ export interface ICallbackDefinition { no_output?: boolean; websocket?: boolean; persistent?: boolean; + compress_payload?: boolean; + compress_threshold?: number; } export interface ICallbackProperty { diff --git a/tests/integration/callbacks/test_compressed_callback.py b/tests/integration/callbacks/test_compressed_callback.py new file mode 100644 index 0000000000..e8066be410 --- /dev/null +++ b/tests/integration/callbacks/test_compressed_callback.py @@ -0,0 +1,62 @@ +""" +Integration tests for callback payload compression. +""" + +import flask +import pytest +from dash import Dash, html, dcc, Input, Output, State +from dash._compression import COMPRESSED_PAYLOAD_FIELD + + +@pytest.mark.parametrize("payload_size", [1, 500_000]) +@pytest.mark.parametrize("compress_threshold", [0, 500_000]) +def test_cbcomp01_always_compress(dash_duo, payload_size, compress_threshold): + """Test that the client sends a compressed body when appropriate.""" + app = Dash(__name__) + + @app.server.before_request + def capture_compression(): + # intercept the request to /_dash-update-component and record whether the payload was compressed + if flask.request.path == "/_dash-update-component": + body = flask.request.get_json(silent=True) or {} + if COMPRESSED_PAYLOAD_FIELD in body: + flask.g.compressed_payload_size = len(body[COMPRESSED_PAYLOAD_FIELD]) + else: + flask.g.compressed_payload_size = None + + @app.callback( + Output("data_size", "children"), + Output("data_compressed", "children"), + Output("data_compressed_size", "children"), + Input("btn", "n_clicks"), + State("store", "data"), + compress_payload=True, + compress_threshold=compress_threshold, + prevent_initial_call=True, + ) + def on_click(n, data): + # log the size of the data and whether it was compressed + return ( + len(data), + repr(flask.g.compressed_payload_size is not None), + flask.g.compressed_payload_size, + ) + + app.layout = html.Div( + [ + html.Button("Click", id="btn"), + html.Div(id="data_size"), + html.Div(id="data_compressed"), + html.Div(id="data_compressed_size"), + dcc.Store(id="store", data="x" * payload_size), + ] + ) + + dash_duo.start_server(app) + dash_duo.find_element("#btn").click() + # assert that the data size matches the expected payload size + dash_duo.wait_for_text_to_equal("#data_size", f"{payload_size}") + # assert that the data was compressed if the payload size is greater than or equal to the compression threshold + dash_duo.wait_for_text_to_equal( + "#data_compressed", repr(payload_size >= compress_threshold) + ) diff --git a/tests/unit/test_payload_compression.py b/tests/unit/test_payload_compression.py new file mode 100644 index 0000000000..0360fd67b2 --- /dev/null +++ b/tests/unit/test_payload_compression.py @@ -0,0 +1,44 @@ +""" +Unit tests for dash._compression.decompress_payload. +""" + +import json +import base64 +import zlib +import pytest + +from dash._compression import decompress_payload, COMPRESSED_PAYLOAD_FIELD + + +def _compress(payload: dict) -> dict: + """Helper: compress a dict and wrap it for decompress_payload.""" + compressed = zlib.compress(json.dumps(payload).encode()) + return {COMPRESSED_PAYLOAD_FIELD: base64.b64encode(compressed).decode()} + + +class TestDecompressPayload: + def test_uncompressed_passthrough(self): + payload = {"output": "out", "inputs": [], "changedPropIds": []} + assert decompress_payload(payload) == payload + + def test_compressed_roundtrip(self): + original = { + "output": "out", + "inputs": [{"id": "a", "property": "value", "value": "x"}], + "changedPropIds": [], + } + assert decompress_payload(_compress(original)) == original + + def test_empty_compressed_field_raises(self): + with pytest.raises(ValueError): + decompress_payload({COMPRESSED_PAYLOAD_FIELD: None}) + + def test_invalid_base64_raises(self): + with pytest.raises(ValueError): + decompress_payload({COMPRESSED_PAYLOAD_FIELD: "not-valid-base64!@#"}) + + def test_invalid_gzip_raises(self): + with pytest.raises(ValueError): + decompress_payload( + {COMPRESSED_PAYLOAD_FIELD: base64.b64encode(b"not gzip").decode()} + ) From 2a72bf8001c1c426b9398c9af86642f2e5d666d8 Mon Sep 17 00:00:00 2001 From: datenzauberai Date: Wed, 29 Jul 2026 22:50:03 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A9=B9=20Fix=20sonar=20issues=20(and?= =?UTF-8?q?=20use=20more=20efficient=20Base64=20encoder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dash/dash-renderer/package-lock.json | 1 + dash/dash-renderer/package.json | 1 + dash/dash-renderer/src/actions/callbacks.ts | 10 +++++--- tests/unit/test_payload_compression.py | 26 +++++++++------------ 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index 2872d92832..395c974c42 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@babel/polyfill": "^7.12.1", "@plotly/dash-component-plugins": "^1.2.3", + "base64-js": "^1.5.1", "cookie": "^1.1.1", "cytoscape": "^3.29.2", "cytoscape-dagre": "^2.5.0", diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index 4e7eb79df4..e0b935b305 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -23,6 +23,7 @@ "dependencies": { "@babel/polyfill": "^7.12.1", "@plotly/dash-component-plugins": "^1.2.3", + "base64-js": "^1.5.1", "cookie": "^1.1.1", "cytoscape": "^3.29.2", "cytoscape-dagre": "^2.5.0", diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 5b6ca1c654..88e99aa083 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -14,6 +14,7 @@ import { assocPath } from 'ramda'; +import {fromByteArray} from 'base64-js'; import {gzipSync} from 'fflate'; import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants'; @@ -536,14 +537,17 @@ function handleServerside( ) { try { const compressed = gzipSync(new TextEncoder().encode(newBody)); - const compressedB64 = btoa( - Array.from(compressed, b => String.fromCharCode(b)).join('') - ); + const compressedB64 = fromByteArray(compressed); newBody = JSON.stringify({ __compressed_payload__: compressedB64 }); } catch (error) { // Fall through to send uncompressed + // eslint-disable-next-line no-console + console.warn( + 'Sending uncompressed payload, because compressing failed:', + error + ); } } diff --git a/tests/unit/test_payload_compression.py b/tests/unit/test_payload_compression.py index 0360fd67b2..c0227c4256 100644 --- a/tests/unit/test_payload_compression.py +++ b/tests/unit/test_payload_compression.py @@ -2,17 +2,18 @@ Unit tests for dash._compression.decompress_payload. """ -import json import base64 +import json import zlib + import pytest -from dash._compression import decompress_payload, COMPRESSED_PAYLOAD_FIELD +from dash._compression import COMPRESSED_PAYLOAD_FIELD, decompress_payload def _compress(payload: dict) -> dict: """Helper: compress a dict and wrap it for decompress_payload.""" - compressed = zlib.compress(json.dumps(payload).encode()) + compressed = zlib.compress(json.dumps(payload).encode(), wbits=zlib.MAX_WBITS + 16) return {COMPRESSED_PAYLOAD_FIELD: base64.b64encode(compressed).decode()} @@ -29,16 +30,11 @@ def test_compressed_roundtrip(self): } assert decompress_payload(_compress(original)) == original - def test_empty_compressed_field_raises(self): - with pytest.raises(ValueError): - decompress_payload({COMPRESSED_PAYLOAD_FIELD: None}) - - def test_invalid_base64_raises(self): - with pytest.raises(ValueError): - decompress_payload({COMPRESSED_PAYLOAD_FIELD: "not-valid-base64!@#"}) - - def test_invalid_gzip_raises(self): + @pytest.mark.parametrize( + "compressed_payload", + [None, "", "not-valid-base64!@#", base64.b64encode(b"not gzip").decode()], + ) + def test_decompress_payload_raises(self, compressed_payload): + payload = {COMPRESSED_PAYLOAD_FIELD: compressed_payload} with pytest.raises(ValueError): - decompress_payload( - {COMPRESSED_PAYLOAD_FIELD: base64.b64encode(b"not gzip").decode()} - ) + decompress_payload(payload) From b533cecc4e66934ffe641c74caf4a8ef476ed60e Mon Sep 17 00:00:00 2001 From: datenzauberai Date: Thu, 30 Jul 2026 10:02:00 +0200 Subject: [PATCH 3/5] Test on all backends (flask, quart, fastapi) --- .../callbacks/test_compressed_callback.py | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/tests/integration/callbacks/test_compressed_callback.py b/tests/integration/callbacks/test_compressed_callback.py index e8066be410..84dff5e2b0 100644 --- a/tests/integration/callbacks/test_compressed_callback.py +++ b/tests/integration/callbacks/test_compressed_callback.py @@ -2,27 +2,61 @@ Integration tests for callback payload compression. """ -import flask import pytest from dash import Dash, html, dcc, Input, Output, State from dash._compression import COMPRESSED_PAYLOAD_FIELD +@pytest.mark.parametrize( + "backend,dash_duo_fixture", + [("flask", "dash_duo"), ("quart", "dash_duo_mp"), ("fastapi", "dash_duo")], +) @pytest.mark.parametrize("payload_size", [1, 500_000]) @pytest.mark.parametrize("compress_threshold", [0, 500_000]) -def test_cbcomp01_always_compress(dash_duo, payload_size, compress_threshold): +def test_cbcomp01_compress_request_payload( + request, dash_duo_fixture, backend, payload_size, compress_threshold +): """Test that the client sends a compressed body when appropriate.""" - app = Dash(__name__) - - @app.server.before_request - def capture_compression(): - # intercept the request to /_dash-update-component and record whether the payload was compressed - if flask.request.path == "/_dash-update-component": - body = flask.request.get_json(silent=True) or {} - if COMPRESSED_PAYLOAD_FIELD in body: - flask.g.compressed_payload_size = len(body[COMPRESSED_PAYLOAD_FIELD]) - else: - flask.g.compressed_payload_size = None + if backend == "quart": + pytest.importorskip( + "quart", reason="Quart extra dependencies are not installed" + ) + pytest.importorskip("hypercorn", reason="hypercorn is not installed") + elif backend == "fastapi": + pytest.importorskip( + "fastapi", reason="fastapi extra dependencies are not installed" + ) + + app = Dash(__name__, backend=backend) + + # intercept the request to /_dash-update-component and record whether the payload was compressed + if backend == "quart": + # async capture for quart + @app.backend.before_request + async def capture_compression(): + request = app.backend.request_adapter() + if request.path == "/_dash-update-component": + body = await request.get_json() or {} + if COMPRESSED_PAYLOAD_FIELD in body: + request.context.compressed_payload_size = len( + body[COMPRESSED_PAYLOAD_FIELD] + ) + else: + request.context.compressed_payload_size = None + + else: + # sync capture for flask and fastapi + @app.backend.before_request + def capture_compression(): + request = app.backend.request_adapter() + if request.path == "/_dash-update-component": + body = request.get_json() or {} + if COMPRESSED_PAYLOAD_FIELD in body: + request.context.compressed_payload_size = len( + body[COMPRESSED_PAYLOAD_FIELD] + ) + else: + request.context.compressed_payload_size = None @app.callback( Output("data_size", "children"), @@ -36,10 +70,13 @@ def capture_compression(): ) def on_click(n, data): # log the size of the data and whether it was compressed + compressed_payload_size = ( + app.backend.request_adapter().context.compressed_payload_size + ) return ( len(data), - repr(flask.g.compressed_payload_size is not None), - flask.g.compressed_payload_size, + repr(compressed_payload_size is not None), + compressed_payload_size, ) app.layout = html.Div( @@ -52,6 +89,7 @@ def on_click(n, data): ] ) + dash_duo = request.getfixturevalue(dash_duo_fixture) dash_duo.start_server(app) dash_duo.find_element("#btn").click() # assert that the data size matches the expected payload size From 142121f320285a92fa933a6f7ef62e2c36302d6f Mon Sep 17 00:00:00 2001 From: datenzauberai Date: Thu, 30 Jul 2026 13:50:01 +0200 Subject: [PATCH 4/5] Set default threshold to 5 kB --- dash/_callback.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 868c1eb842..15452925a0 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -91,7 +91,7 @@ def callback( mcp_enabled: Optional[bool] = None, mcp_expose_docstring: Optional[bool] = None, compress_payload: bool = False, - compress_threshold: int = 500_000, + compress_threshold: int = 5_000, **_kwargs, ) -> Callable[[Callable[Params, ReturnVar]], Callable[Params, ReturnVar]]: """ @@ -198,7 +198,7 @@ def callback( :param compress_threshold: The size threshold in bytes above which the payload will be compressed when `compress_payload` is True. Set to 0 to always compress regardless - of size. Defaults to 500,000 bytes (500 KB). + of size. Defaults to 5,000 bytes (5 kB). """ background_spec: Any = None @@ -318,7 +318,7 @@ def insert_callback( mcp_enabled=None, mcp_expose_docstring=None, compress_payload: bool = False, - compress_threshold: int = 500_000, + compress_threshold: int = 5_000, ) -> str: if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -793,7 +793,7 @@ def register_callback( mcp_enabled=_kwargs.get("mcp_enabled", None), mcp_expose_docstring=_kwargs.get("mcp_expose_docstring"), compress_payload=_kwargs.get("compress_payload", False), - compress_threshold=_kwargs.get("compress_threshold", 500_000), + compress_threshold=_kwargs.get("compress_threshold", 5_000), ) # pylint: disable=too-many-locals From 0e812bcd19d9483c2a2cd59545acbaeea582cc4d Mon Sep 17 00:00:00 2001 From: datenzauberai Date: Thu, 30 Jul 2026 15:02:25 +0200 Subject: [PATCH 5/5] Send as binary instead of Base64 encoded. --- dash/_compression.py | 40 +++++-------------- dash/backends/_fastapi.py | 16 ++++---- dash/backends/_flask.py | 16 ++++---- dash/backends/_quart.py | 13 +++--- dash/dash-renderer/package-lock.json | 1 - dash/dash-renderer/package.json | 1 - dash/dash-renderer/src/actions/callbacks.ts | 12 +++--- .../callbacks/test_compressed_callback.py | 40 +++++-------------- tests/unit/test_payload_compression.py | 30 +++++++------- 9 files changed, 65 insertions(+), 104 deletions(-) diff --git a/dash/_compression.py b/dash/_compression.py index 8575bdb77d..6dc59488bd 100644 --- a/dash/_compression.py +++ b/dash/_compression.py @@ -5,50 +5,30 @@ particularly useful for callbacks with large data payloads. """ -import base64 import json import zlib from typing import Any -COMPRESSED_PAYLOAD_FIELD = "__compressed_payload__" - -def decompress_payload(request_body: dict[str, Any]) -> dict[str, Any]: +def decompress_payload(data: bytes) -> Any: """ - Decompress a callback request body if it was compressed. - - If the request body contains a ``__compressed_payload__`` field, - this function decompresses the gzipped payload and returns the parsed JSON dictionary. + Decompress a gzip-compressed callback request body. - If the request body is not compressed, it is returned as-is. + The data is expected to be the raw bytes of a gzip-compressed UTF-8 JSON payload, + as produced by fflate's ``gzipSync`` on the client side. Args: - request_body: The parsed request body as a dictionary. + data: The raw compressed request body bytes. Returns: The decompressed and parsed callback request dictionary. Raises: - ValueError: If the compressed data is invalid or cannot be decompressed. + ValueError: If the data cannot be decompressed or parsed. """ - if COMPRESSED_PAYLOAD_FIELD not in request_body: - # return the original request body if it does not contain the compressed payload marker - return request_body - - # Extract and decode the compressed data - compressed_data_b64_encoded = request_body.get(COMPRESSED_PAYLOAD_FIELD) - if not compressed_data_b64_encoded: - raise ValueError("Failed to decompress callback payload.") - try: - compressed_data_binary = base64.b64decode(compressed_data_b64_encoded) - # fflate's gzipSync produces gzip format, so we need to signal to zlib that it's gzip by using 16 + MAX_WBITS - decompressed_data_binary = zlib.decompress(compressed_data_binary, zlib.MAX_WBITS | 16) - decompressed_body = json.loads(decompressed_data_binary.decode("utf-8")) - - return decompressed_body - + # fflate's gzipSync produces gzip format; use 16 + MAX_WBITS to signal gzip + decompressed = zlib.decompress(data, zlib.MAX_WBITS | 16) + return json.loads(decompressed.decode("utf-8")) except Exception as e: - raise ValueError( - "Failed to decompress callback payload." - ) from e + raise ValueError("Failed to decompress callback payload.") from e diff --git a/dash/backends/_fastapi.py b/dash/backends/_fastapi.py index 8620dde4cd..d0106fc271 100644 --- a/dash/backends/_fastapi.py +++ b/dash/backends/_fastapi.py @@ -329,7 +329,8 @@ def setup_catchall(self, dash_app: Dash): and passed through the middleware, which is necessary for features like authentication and timing to work correctly on all routes. FastAPI will match this catch-all route for any path that isn't matched by a more specific route, allowing the middleware to - process the request and then return the appropriate response (e.g., 404 if no Dash route matches).""" + process the request and then return the appropriate response (e.g., 404 if no Dash route matches). + """ def _setup_catchall(self): try: @@ -550,9 +551,10 @@ def add_redirect_rule(self, app, fullname, path): def serve_callback(self, dash_app: Dash): async def _dispatch(request: Request): # pylint: disable=unused-argument # pylint: disable=protected-access - body = self.request_adapter().get_json() - # Decompress payload if it was compressed - body = decompress_payload(body) + if "gzip" in request.headers.get("content-encoding", ""): + body = decompress_payload(await self.request_adapter()._request.body()) + else: + body = self.request_adapter().get_json() cb_ctx = dash_app._initialize_context( body ) # pylint: disable=protected-access @@ -751,9 +753,9 @@ async def websocket_handler(websocket: WebSocket): ) # Track pending callbacks: concurrent.futures.Future (sync/threadpool) # or asyncio.Task (async/event-loop). - pending_callbacks: Dict[ - str, concurrent.futures.Future | asyncio.Future - ] = {} + pending_callbacks: Dict[str, concurrent.futures.Future | asyncio.Future] = ( + {} + ) # Start sender task to drain outbound queue (sends pre-serialized text) # pylint: disable=protected-access diff --git a/dash/backends/_flask.py b/dash/backends/_flask.py index 7d2bfd3ec6..024875bdb8 100644 --- a/dash/backends/_flask.py +++ b/dash/backends/_flask.py @@ -33,7 +33,6 @@ from dash._utils import parse_version from .base_server import BaseDashServer, RequestAdapter, ResponseAdapter - if TYPE_CHECKING: # pragma: no cover - typing only from dash import Dash @@ -253,9 +252,10 @@ def add_redirect_rule(self, app, fullname, path): # pylint: disable=unused-argument def serve_callback(self, dash_app: Dash): def _dispatch(): - body = request.get_json() - # Decompress payload if it was compressed - body = decompress_payload(body) + if "gzip" in request.headers.get("Content-Encoding", ""): + body = decompress_payload(request.data) + else: + body = request.get_json() # pylint: disable=protected-access cb_ctx = dash_app._initialize_context(body) func = dash_app._prepare_callback(cb_ctx, body) @@ -274,9 +274,11 @@ def _dispatch(): return cb_ctx.dash_response.set_response(data=response_data) async def _dispatch_async(): - body = request.get_json() - # Decompress payload if it was compressed - body = decompress_payload(body) + if "gzip" in request.headers.get("Content-Encoding", ""): + body = decompress_payload(request.data) + else: + body = request.get_json() + # pylint: disable=protected-access cb_ctx = dash_app._initialize_context(body) func = dash_app._prepare_callback(cb_ctx, body) args = dash_app._inputs_to_vals(cb_ctx.inputs_list + cb_ctx.states_list) diff --git a/dash/backends/_quart.py b/dash/backends/_quart.py index 83fe7125c8..94eac35b96 100644 --- a/dash/backends/_quart.py +++ b/dash/backends/_quart.py @@ -386,9 +386,10 @@ def add_redirect_rule(self, app, fullname, path): def serve_callback(self, dash_app: Dash): # type: ignore[override] # Quart always async async def _dispatch(): adapter = QuartRequestAdapter() - body = await adapter.get_json() - # Decompress payload if it was compressed - body = decompress_payload(body) + if "gzip" in adapter.request.headers.get("Content-Encoding", ""): + body = decompress_payload(await adapter.request.get_data()) + else: + body = await adapter.get_json() # pylint: disable=protected-access cb_ctx = dash_app._initialize_context(body) # pylint: disable=protected-access @@ -584,9 +585,9 @@ async def websocket_handler(): # pylint: disable=too-many-branches ) # Track pending callbacks: concurrent.futures.Future (sync/threadpool) # or asyncio.Task (async/event-loop). - pending_callbacks: Dict[ - str, concurrent.futures.Future | asyncio.Future - ] = {} + pending_callbacks: Dict[str, concurrent.futures.Future | asyncio.Future] = ( + {} + ) # Start sender task to drain outbound queue (sends pre-serialized text) # pylint: disable=protected-access diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index 395c974c42..2872d92832 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@babel/polyfill": "^7.12.1", "@plotly/dash-component-plugins": "^1.2.3", - "base64-js": "^1.5.1", "cookie": "^1.1.1", "cytoscape": "^3.29.2", "cytoscape-dagre": "^2.5.0", diff --git a/dash/dash-renderer/package.json b/dash/dash-renderer/package.json index e0b935b305..4e7eb79df4 100644 --- a/dash/dash-renderer/package.json +++ b/dash/dash-renderer/package.json @@ -23,7 +23,6 @@ "dependencies": { "@babel/polyfill": "^7.12.1", "@plotly/dash-component-plugins": "^1.2.3", - "base64-js": "^1.5.1", "cookie": "^1.1.1", "cytoscape": "^3.29.2", "cytoscape-dagre": "^2.5.0", diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 88e99aa083..44d617321a 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -14,7 +14,6 @@ import { assocPath } from 'ramda'; -import {fromByteArray} from 'base64-js'; import {gzipSync} from 'fflate'; import {STATUS, JWT_EXPIRED_MESSAGE} from '../constants/constants'; @@ -529,6 +528,8 @@ function handleServerside( moreArgs = moreArgs.filter(([_, __, single]) => !single); } + let fetchBody: BodyInit = newBody; + // Compress payload if enabled and size threshold is met if ( compressPayload && @@ -536,11 +537,8 @@ function handleServerside( newBody.length > compressThreshold ) { try { - const compressed = gzipSync(new TextEncoder().encode(newBody)); - const compressedB64 = fromByteArray(compressed); - newBody = JSON.stringify({ - __compressed_payload__: compressedB64 - }); + fetchBody = gzipSync(new TextEncoder().encode(newBody)); + headers['Content-Encoding'] = 'gzip'; } catch (error) { // Fall through to send uncompressed // eslint-disable-next-line no-console @@ -556,7 +554,7 @@ function handleServerside( mergeDeepRight(config.fetch, { method: 'POST', headers, - body: newBody + body: fetchBody }) ); }; diff --git a/tests/integration/callbacks/test_compressed_callback.py b/tests/integration/callbacks/test_compressed_callback.py index 84dff5e2b0..c57501c175 100644 --- a/tests/integration/callbacks/test_compressed_callback.py +++ b/tests/integration/callbacks/test_compressed_callback.py @@ -4,7 +4,6 @@ import pytest from dash import Dash, html, dcc, Input, Output, State -from dash._compression import COMPRESSED_PAYLOAD_FIELD @pytest.mark.parametrize( @@ -29,34 +28,17 @@ def test_cbcomp01_compress_request_payload( app = Dash(__name__, backend=backend) - # intercept the request to /_dash-update-component and record whether the payload was compressed - if backend == "quart": - # async capture for quart - @app.backend.before_request - async def capture_compression(): - request = app.backend.request_adapter() - if request.path == "/_dash-update-component": - body = await request.get_json() or {} - if COMPRESSED_PAYLOAD_FIELD in body: - request.context.compressed_payload_size = len( - body[COMPRESSED_PAYLOAD_FIELD] - ) - else: - request.context.compressed_payload_size = None - - else: - # sync capture for flask and fastapi - @app.backend.before_request - def capture_compression(): - request = app.backend.request_adapter() - if request.path == "/_dash-update-component": - body = request.get_json() or {} - if COMPRESSED_PAYLOAD_FIELD in body: - request.context.compressed_payload_size = len( - body[COMPRESSED_PAYLOAD_FIELD] - ) - else: - request.context.compressed_payload_size = None + @app.backend.before_request + def capture_compression(): + # intercept the request to /_dash-update-component and record whether the payload was compressed + req = app.backend.request_adapter() + if req.path == "/_dash-update-component": + if "gzip" in req.headers.get("Content-Encoding", ""): + req.context.compressed_payload_size = int( + req.headers.get("content-length", 0) + ) + else: + req.context.compressed_payload_size = None @app.callback( Output("data_size", "children"), diff --git a/tests/unit/test_payload_compression.py b/tests/unit/test_payload_compression.py index c0227c4256..044ec5c92e 100644 --- a/tests/unit/test_payload_compression.py +++ b/tests/unit/test_payload_compression.py @@ -2,39 +2,37 @@ Unit tests for dash._compression.decompress_payload. """ -import base64 import json import zlib import pytest -from dash._compression import COMPRESSED_PAYLOAD_FIELD, decompress_payload +from dash._compression import decompress_payload -def _compress(payload: dict) -> dict: - """Helper: compress a dict and wrap it for decompress_payload.""" - compressed = zlib.compress(json.dumps(payload).encode(), wbits=zlib.MAX_WBITS + 16) - return {COMPRESSED_PAYLOAD_FIELD: base64.b64encode(compressed).decode()} +def _gzip(payload: dict) -> bytes: + """Helper: gzip-compress a dict to raw bytes.""" + return zlib.compress(json.dumps(payload).encode(), wbits=zlib.MAX_WBITS + 16) class TestDecompressPayload: - def test_uncompressed_passthrough(self): - payload = {"output": "out", "inputs": [], "changedPropIds": []} - assert decompress_payload(payload) == payload - def test_compressed_roundtrip(self): original = { "output": "out", "inputs": [{"id": "a", "property": "value", "value": "x"}], "changedPropIds": [], } - assert decompress_payload(_compress(original)) == original + assert decompress_payload(_gzip(original)) == original @pytest.mark.parametrize( - "compressed_payload", - [None, "", "not-valid-base64!@#", base64.b64encode(b"not gzip").decode()], + "bad_data", + [b"", b"not gzip at all", b"\x1f\x8b\x00truncated"], ) - def test_decompress_payload_raises(self, compressed_payload): - payload = {COMPRESSED_PAYLOAD_FIELD: compressed_payload} + def test_decompress_invalid_raises(self, bad_data): + with pytest.raises(ValueError): + decompress_payload(bad_data) + + def test_decompress_valid_gzip_invalid_json_raises(self): + bad_json = zlib.compress(b"not json", wbits=zlib.MAX_WBITS + 16) with pytest.raises(ValueError): - decompress_payload(payload) + decompress_payload(bad_json)