Repository: zauberzeug/nicegui
Pull request #4128 (2024-12-20 17:23:56)

Introduce a "Map of NiceGUI"
This PR adds an overview of the NiceGUI namespace to the documentation page.

- [x] It's still a draft, because I'm planning to add links to the individual documentation pages where possible.
- [x] And although I like the blue color to highlight the code snippets, it can be confused with links. So maybe we find a better way to highlight them.



Patch:
diff --git a/CITATION.cff b/CITATION.cff
index 1d513e8a..2caf16b4 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,10 +8,10 @@ authors:
     given-names: Rodja
     orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: 2.9.0
-date-released: '2024-12-19'
+version: 2.9.1
+date-released: '2025-01-06'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.14525082
+doi: 10.5281/zenodo.14605360
 identifiers:
   - description: DOI for this application's record on Zenodo
     type: doi
diff --git a/docker-compose.yml b/docker-compose.yml
index b23b9b0c..981e0fd3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -19,7 +19,6 @@ services:
     image: traefik:v2.3
     command:
       - --providers.docker
-      - --api.insecure=true
       - --accesslog # http access log
       - --log #Traefik log, for configurations and errors
       - --api # Enable the Dashboard and API
diff --git a/examples/chat_with_ai/main.py b/examples/chat_with_ai/main.py
index e9be81c0..9021a6bf 100755
--- a/examples/chat_with_ai/main.py
+++ b/examples/chat_with_ai/main.py
@@ -9,7 +9,7 @@ OPENAI_API_KEY = 'not-set'  # TODO: set your OpenAI API key here
 
 @ui.page('/')
 def main():
-    llm = ChatOpenAI(model_name='gpt-3.5-turbo', streaming=True, openai_api_key=OPENAI_API_KEY)
+    llm = ChatOpenAI(model_name='gpt-4o-mini', streaming=True, openai_api_key=OPENAI_API_KEY)
 
     async def send() -> None:
         question = text.value
diff --git a/examples/openai_assistant/main.py b/examples/openai_assistant/main.py
new file mode 100755
index 00000000..0d0709e9
--- /dev/null
+++ b/examples/openai_assistant/main.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+from openai import AsyncOpenAI
+from openai.types.beta.assistant_stream_event import ThreadMessageInProgress
+from openai.types.beta.threads import MessageDeltaEvent, TextDeltaBlock
+
+from nicegui import ui
+
+client = AsyncOpenAI(api_key='provide your OpenAI API key here')
+
+
+@ui.page('/')
+async def main():
+    assistant = await client.beta.assistants.create(
+        name='NiceGUI Assistant',
+        instructions='''
+            You are a personal assistant for NiceGUI developers.
+            Your sole focus is to help with questions about the NiceGUI framework.
+            You are precise and concise.
+            Stay on the topic.
+            Very short answers are preferred, but always be friendly and polite.
+        ''',
+        tools=[{'type': 'code_interpreter'}],
+        model='gpt-4o-mini',
+    )
+
+    thread = await client.beta.threads.create()
+
+    async def send() -> None:
+        response.content = ''
+        spinner = ui.spinner(size='5em', type='comment').classes('mx-auto')
+        await client.beta.threads.messages.create(
+            thread_id=thread.id,
+            role='user',
+            content=question.value,
+        )
+        stream = await client.beta.threads.runs.create(
+            assistant_id=assistant.id,
+            thread_id=thread.id,
+            stream=True,
+        )
+        async for chunk in stream:
+            if isinstance(chunk, ThreadMessageInProgress):
+                spinner.delete()
+            # NOTE: the stream contains a lot of different types so we need to filter out the ones we don't need
+            if not isinstance(chunk.data, MessageDeltaEvent) or not chunk.data.delta.content:
+                continue
+            content = chunk.data.delta.content[0]
+            if not isinstance(content, TextDeltaBlock) or content.text is None or content.text.value is None:
+                continue
+            response.content += content.text.value
+
+    with ui.column().classes('mx-auto w-full max-w-xl my-16'):
+        ui.label('NiceGUI Assistant').classes('text-2xl font-bold mx-auto')
+        question = ui.input(value='Why does NiceGUI use async/await?') \
+            .classes('w-full self-center mt-4').props('hint="Ask your question" dense') \
+            .on('keydown.enter', send)
+        response = ui.markdown().classes('mx-4 mt-2')
+        ui.timer(0, send, once=True)  # NOTE: we send the prepared demo question immediately
+
+ui.run()
diff --git a/examples/openai_assistant/requirements.txt b/examples/openai_assistant/requirements.txt
new file mode 100644
index 00000000..c172a027
--- /dev/null
+++ b/examples/openai_assistant/requirements.txt
@@ -0,0 +1,1 @@
+openai==1.58.1
diff --git a/examples/redis_storage/Dockerfile b/examples/redis_storage/Dockerfile
new file mode 100644
index 00000000..6c46c0e8
--- /dev/null
+++ b/examples/redis_storage/Dockerfile
@@ -0,0 +1,3 @@
+FROM zauberzeug/nicegui:2.8.0
+
+RUN python -m pip install redis
diff --git a/examples/redis_storage/docker-compose.yml b/examples/redis_storage/docker-compose.yml
new file mode 100644
index 00000000..b289b486
--- /dev/null
+++ b/examples/redis_storage/docker-compose.yml
@@ -0,0 +1,35 @@
+services:
+  x-nicegui: &nicegui-service
+    build:
+      context: .
+    environment:
+      - NICEGUI_REDIS_URL=redis://redis:6379
+    volumes:
+      - ./:/app
+      - ../../nicegui:/app/nicegui
+    labels:
+      - traefik.enable=true
+      - traefik.http.routers.nicegui.rule=PathPrefix(`/`)
+      - traefik.http.services.nicegui.loadbalancer.server.port=8080
+      - traefik.http.services.nicegui.loadbalancer.sticky.cookie=true
+
+  nicegui1:
+    <<: *nicegui-service
+
+  nicegui2:
+    <<: *nicegui-service
+
+  redis:
+    image: redis:alpine
+    ports:
+      - "6379:6379"
+
+  proxy:
+    image: traefik:v2.10
+    command:
+      - --providers.docker
+      - --entrypoints.web.address=:80
+    ports:
+      - "8080:80"
+    volumes:
+      - /var/run/docker.sock:/var/run/docker.sock
diff --git a/examples/redis_storage/main.py b/examples/redis_storage/main.py
new file mode 100755
index 00000000..c46029ec
--- /dev/null
+++ b/examples/redis_storage/main.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+from nicegui import app, ui
+
+
+@ui.page('/')
+async def index():
+    ui.input('general').bind_value(app.storage.general, 'text')
+    ui.input('user').bind_value(app.storage.user, 'text')
+    await ui.context.client.connected()
+    ui.input('tab').bind_value(app.storage.tab, 'text')
+
+
+ui.run(storage_secret='your private key to secure the browser session cookie')
diff --git a/nicegui/__init__.py b/nicegui/__init__.py
index a1b2f472..a3b27dfe 100644
--- a/nicegui/__init__.py
+++ b/nicegui/__init__.py
@@ -1,4 +1,4 @@
-from . import elements, html, run, ui
+from . import elements, html, run, storage, ui
 from .api_router import APIRouter
 from .app.app import App
 from .client import Client
@@ -20,5 +20,6 @@ __all__ = [
     'elements',
     'html',
     'run',
+    'storage',
     'ui',
 ]
diff --git a/nicegui/air.py b/nicegui/air.py
index ffa8233f..e7c249e9 100644
--- a/nicegui/air.py
+++ b/nicegui/air.py
@@ -59,6 +59,7 @@ class Air:
                 content=data['body'],
             )
             response = await self.client.send(request)
+            self.client.cookies.clear()
             instance_id = data['instance-id']
             content = response.content.replace(
                 b'const extraHeaders = {};',
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 84d11505..83850344 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -46,6 +46,9 @@ class App(FastAPI):
         self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self._exception_handlers: List[Callable[..., Any]] = [log.exception]
 
+        self.on_startup(self.storage.general.initialize)
+        self.on_shutdown(self.storage.on_shutdown)
+
     @property
     def is_starting(self) -> bool:
         """Return whether NiceGUI is starting."""
diff --git a/nicegui/element.py b/nicegui/element.py
index e79d8112..b9fa360e 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -364,7 +364,7 @@ class Element(Visibility):
         if handler or js_handler:
             listener = EventListener(
                 element_id=self.id,
-                type=helpers.kebab_to_camel_case(type),
+                type=helpers.event_type_to_camel_case(type),
                 args=[args] if args and isinstance(args[0], str) else args,  # type: ignore
                 handler=handler,
                 js_handler=js_handler,
diff --git a/nicegui/elements/editor.js b/nicegui/elements/editor.js
index 9ab25a6b..d77f1c4e 100644
--- a/nicegui/elements/editor.js
+++ b/nicegui/elements/editor.js
@@ -8,7 +8,7 @@ export default {
       <template v-for="(_, slot) in $slots" v-slot:[slot]="slotProps">
         <slot :name="slot" v-bind="slotProps || {}" />
       </template>
-    </q-input>
+    </q-editor>
   `,
   props: {
     value: String,
diff --git a/nicegui/elements/line_plot.py b/nicegui/elements/line_plot.py
index 7f3b0918..d90ef777 100644
--- a/nicegui/elements/line_plot.py
+++ b/nicegui/elements/line_plot.py
@@ -1,4 +1,4 @@
-from typing import Any, List
+from typing import Any, List, Literal, Tuple, Union
 
 from .pyplot import Pyplot
 
@@ -42,11 +42,19 @@ class LinePlot(Pyplot):
         self._convert_to_html()
         return self
 
-    def push(self, x: List[float], Y: List[List[float]]) -> None:
+    def push(self,
+             x: List[float],
+             Y: List[List[float]],
+             *,
+             x_limits: Union[None, Literal['auto'], Tuple[float, float]] = 'auto',
+             y_limits: Union[None, Literal['auto'], Tuple[float, float]] = 'auto',
+             ) -> None:
         """Push new data to the plot.
 
         :param x: list of x values
         :param Y: list of lists of y values (one list per line)
+        :param x_limits: new x limits (tuple of floats, or "auto" to fit the data points, or ``None`` to leave unchanged)
+        :param y_limits: new y limits (tuple of floats, or "auto" to fit the data points, or ``None`` to leave unchanged)
         """
         self.push_counter += 1
 
@@ -61,15 +69,25 @@ class LinePlot(Pyplot):
             line.set_xdata(self.x)
             line.set_ydata(self.Y[i])
 
-        flat_y = [y_i for y in self.Y for y_i in y]
-        min_x = min(self.x)
-        max_x = max(self.x)
-        min_y = min(flat_y)
-        max_y = max(flat_y)
-        pad_x = 0.01 * (max_x - min_x)
-        pad_y = 0.01 * (max_y - min_y)
-        self.fig.gca().set_xlim(min_x - pad_x, max_x + pad_x)
-        self.fig.gca().set_ylim(min_y - pad_y, max_y + pad_y)
+        if isinstance(x_limits, tuple):
+            self.fig.gca().set_xlim(*x_limits)
+        elif x_limits == 'auto':
+            min_x = min(self.x)
+            max_x = max(self.x)
+            if min_x != max_x:
+                pad_x = 0.01 * (max_x - min_x)
+                self.fig.gca().set_xlim(min_x - pad_x, max_x + pad_x)
+
+        if isinstance(y_limits, tuple):
+            self.fig.gca().set_ylim(*y_limits)
+        elif y_limits == 'auto':
+            flat_y = [y_i for y in self.Y for y_i in y]
+            min_y = min(flat_y)
+            max_y = max(flat_y)
+            if min_y != max_y:
+                pad_y = 0.01 * (max_y - min_y)
+                self.fig.gca().set_ylim(min_y - pad_y, max_y + pad_y)
+
         self._convert_to_html()
         self.update()
 
diff --git a/nicegui/functions/clipboard.py b/nicegui/functions/clipboard.py
index a0740c28..43fa3beb 100644
--- a/nicegui/functions/clipboard.py
+++ b/nicegui/functions/clipboard.py
@@ -1,7 +1,16 @@
-from .. import json
+import io
+from typing import Union
+
+from .. import json, optional_features
 from ..logging import log
 from .javascript import run_javascript
 
+try:
+    from PIL import Image as PIL_Image
+    optional_features.register('pillow')
+except ImportError:
+    pass
+
 
 async def read() -> str:
     """Read text from the clipboard.
@@ -36,3 +45,30 @@ def write(text: str) -> None:
             console.error('Clipboard API is only available in secure contexts (HTTPS or localhost).')
         }}
     ''')
+
+
+async def read_image() -> Union['PIL_Image.Image', None]:
+    """Read PIL images from the clipboard.
+
+    Note: This function only works in secure contexts (HTTPS or localhost) and requires Pillow to be installed.
+    """
+    if not optional_features.has('pillow'):
+        log.warning('Pillow is not installed, so we cannot read images from the clipboard.')
+        return None
+    content = await run_javascript('''
+        if (navigator.clipboard) {
+            const items = await navigator.clipboard.read();
+            for (const item of items) {
+                if (item.types.length > 0 && /^image/.test(item.types[0])) {
+                    return await item.getType(item.types[0]);
+                }
+            }
+        }
+        else {
+            console.error('Clipboard API is only available in secure contexts (HTTPS or localhost).');
+        }
+    ''', timeout=5)
+    if not content:
+        return None
+    buffer = io.BytesIO(content)
+    return PIL_Image.open(buffer)
diff --git a/nicegui/helpers.py b/nicegui/helpers.py
index c660e8e6..8c32d8dc 100644
--- a/nicegui/helpers.py
+++ b/nicegui/helpers.py
@@ -101,3 +101,8 @@ def schedule_browser(host: str, port: int) -> Tuple[threading.Thread, threading.
 def kebab_to_camel_case(string: str) -> str:
     """Convert a kebab-case string to camelCase."""
     return ''.join(word.capitalize() if i else word for i, word in enumerate(string.split('-')))
+
+
+def event_type_to_camel_case(string: str) -> str:
+    """Convert an event type string to camelCase."""
+    return '.'.join(kebab_to_camel_case(part) if part != '-' else part for part in string.split('.'))
diff --git a/nicegui/native/__init__.py b/nicegui/native/__init__.py
index 2fb64809..cabde5d0 100644
--- a/nicegui/native/__init__.py
+++ b/nicegui/native/__init__.py
@@ -1,4 +1,4 @@
-from .native import WindowProxy, method_queue, response_queue
+from .native import WindowProxy, create_queues, method_queue, remove_queues, response_queue
 from .native_config import NativeConfig
 from .native_mode import activate, find_open_port
 
@@ -6,7 +6,9 @@ __all__ = [
     'NativeConfig',
     'WindowProxy',
     'activate',
+    'create_queues',
     'find_open_port',
     'method_queue',
+    'remove_queues',
     'response_queue',
 ]
diff --git a/nicegui/native/native.py b/nicegui/native/native.py
index f432f8e8..db777506 100644
--- a/nicegui/native/native.py
+++ b/nicegui/native/native.py
@@ -2,13 +2,34 @@
 import inspect
 import warnings
 from multiprocessing import Queue
-from typing import Any, Callable, Tuple
+from typing import Any, Callable, Optional, Tuple
 
 from .. import run
 from ..logging import log
 
-method_queue: Queue = Queue()
-response_queue: Queue = Queue()
+method_queue: Optional[Queue] = None
+response_queue: Optional[Queue] = None
+
+
+def create_queues() -> None:
+    """Create the message queues."""
+    global method_queue, response_queue  # pylint: disable=global-statement # noqa: PLW0603
+    method_queue = Queue()
+    response_queue = Queue()
+
+
+def remove_queues() -> None:
+    """Remove the message queues by closing them and waiting for threads to finish."""
+    global method_queue, response_queue  # pylint: disable=global-statement # noqa: PLW0603
+    if method_queue is not None:
+        method_queue.close()
+        method_queue.join_thread()
+        method_queue = None
+    if response_queue is not None:
+        response_queue.close()
+        response_queue.join_thread()
+        response_queue = None
+
 
 try:
     with warnings.catch_warnings():
@@ -120,11 +141,14 @@ try:
 
         def _send(self, *args: Any, **kwargs: Any) -> None:
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
+            assert method_queue is not None
             method_queue.put((name, args, kwargs))
 
         async def _request(self, *args: Any, **kwargs: Any) -> Any:
             def wrapper(*args: Any, **kwargs: Any) -> Any:
                 try:
+                    assert method_queue is not None
+                    assert response_queue is not None
                     method_queue.put((name, args, kwargs))
                     return response_queue.get()  # wait for the method to be called and writing its result to the queue
                 except Exception:
diff --git a/nicegui/native/native_mode.py b/nicegui/native/native_mode.py
index fde7fc36..d0de71b9 100644
--- a/nicegui/native/native_mode.py
+++ b/nicegui/native/native_mode.py
@@ -104,6 +104,7 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
         while not core.app.is_stopped:
             time.sleep(0.1)
         _thread.interrupt_main()
+        native.remove_queues()
 
     if not optional_features.has('webview'):
         log.error('Native mode is not supported in this configuration.\n'
@@ -111,6 +112,7 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
         sys.exit(1)
 
     mp.freeze_support()
+    native.create_queues()
     args = host, port, title, width, height, fullscreen, frameless, native.method_queue, native.response_queue
     process = mp.Process(target=_open_window, args=args, daemon=True)
     process.start()
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 893b4f81..fa896b1b 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -7,7 +7,6 @@ from typing import Any, Dict
 
 import socketio
 from fastapi import HTTPException, Request
-from fastapi.middleware.gzip import GZipMiddleware
 from fastapi.responses import FileResponse, Response
 
 from . import air, background_tasks, binding, core, favicon, helpers, json, run, welcome
@@ -17,7 +16,6 @@ from .dependencies import js_components, libraries, resources
 from .error import error_content
 from .json import NiceGUIJSONResponse
 from .logging import log
-from .middlewares import RedirectWithPrefixMiddleware
 from .page import page
 from .slot import Slot
 from .staticfiles import CacheControlledStaticFiles
@@ -54,8 +52,6 @@ app.mount('/_nicegui_ws/', sio_app)
 mimetypes.add_type('text/javascript', '.js')
 mimetypes.add_type('text/css', '.css')
 
-app.add_middleware(GZipMiddleware)
-app.add_middleware(RedirectWithPrefixMiddleware)
 static_files = CacheControlledStaticFiles(
     directory=(Path(__file__).parent / 'static').resolve(),
     follow_symlink=True,
@@ -176,6 +172,8 @@ async def _on_handshake(sid: str, data: Dict[str, Any]) -> bool:
         client.environ = sio.get_environ(sid)
         await sio.enter_room(sid, client.id)
     client.handle_handshake(data.get('next_message_id'))
+    assert client.tab_id is not None
+    await core.app.storage._create_tab_storage(client.tab_id)  # pylint: disable=protected-access
     return True
 
 
diff --git a/nicegui/optional_features.py b/nicegui/optional_features.py
index 89fe0e2e..dac67191 100644
--- a/nicegui/optional_features.py
+++ b/nicegui/optional_features.py
@@ -10,8 +10,9 @@ FEATURE = Literal[
     'plotly',
     'polars',
     'pyecharts',
-    'webview',
+    'redis',
     'sass',
+    'webview',
 ]
 
 
diff --git a/nicegui/persistence/__init__.py b/nicegui/persistence/__init__.py
new file mode 100644
index 00000000..64334ee4
--- /dev/null
+++ b/nicegui/persistence/__init__.py
@@ -0,0 +1,11 @@
+from .file_persistent_dict import FilePersistentDict
+from .persistent_dict import PersistentDict
+from .read_only_dict import ReadOnlyDict
+from .redis_persistent_dict import RedisPersistentDict
+
+__all__ = [
+    'FilePersistentDict',
+    'PersistentDict',
+    'ReadOnlyDict',
+    'RedisPersistentDict',
+]
diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py
new file mode 100644
index 00000000..5bf6973d
--- /dev/null
+++ b/nicegui/persistence/file_persistent_dict.py
@@ -0,0 +1,48 @@
+from pathlib import Path
+from typing import Optional
+
+import aiofiles
+
+from nicegui import background_tasks, core, json
+from nicegui.logging import log
+
+from .persistent_dict import PersistentDict
+
+
+class FilePersistentDict(PersistentDict):
+
+    def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None:
+        self.filepath = filepath
+        self.encoding = encoding
+        self.indent = indent
+        super().__init__(data={}, on_change=self.backup)
+
+    async def initialize(self) -> None:
+        try:
+            if self.filepath.exists():
+                async with aiofiles.open(self.filepath, encoding=self.encoding) as f:
+                    data = json.loads(await f.read())
+            else:
+                data = {}
+            self.update(data)
+        except Exception:
+            log.warning(f'Could not load storage file {self.filepath}')
+
+    def backup(self) -> None:
+        """Back up the data to the given file path."""
+        if not self.filepath.exists():
+            if not self:
+                return
+            self.filepath.parent.mkdir(exist_ok=True)
+
+        async def backup() -> None:
+            async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f:
+                await f.write(json.dumps(self, indent=self.indent))
+        if core.loop:
+            background_tasks.create_lazy(backup(), name=self.filepath.stem)
+        else:
+            core.app.on_startup(backup())
+
+    def clear(self) -> None:
+        super().clear()
+        self.filepath.unlink(missing_ok=True)
diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/persistent_dict.py
new file mode 100644
index 00000000..c22817cc
--- /dev/null
+++ b/nicegui/persistence/persistent_dict.py
@@ -0,0 +1,13 @@
+import abc
+
+from nicegui import observables
+
+
+class PersistentDict(observables.ObservableDict, abc.ABC):
+
+    @abc.abstractmethod
+    async def initialize(self) -> None:
+        """Load initial data from the persistence layer."""
+
+    async def close(self) -> None:
+        """Clean up the persistence layer."""
diff --git a/nicegui/persistence/read_only_dict.py b/nicegui/persistence/read_only_dict.py
new file mode 100644
index 00000000..2af9a0e2
--- /dev/null
+++ b/nicegui/persistence/read_only_dict.py
@@ -0,0 +1,24 @@
+from collections.abc import MutableMapping
+from typing import Any, Dict, Iterator
+
+
+class ReadOnlyDict(MutableMapping):
+
+    def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None:
+        self._data: Dict[Any, Any] = data
+        self._write_error_message: str = write_error_message
+
+    def __getitem__(self, item: Any) -> Any:
+        return self._data[item]
+
+    def __setitem__(self, key: Any, value: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __delitem__(self, key: Any) -> None:
+        raise TypeError(self._write_error_message)
+
+    def __iter__(self) -> Iterator:
+        return iter(self._data)
+
+    def __len__(self) -> int:
+        return len(self._data)
diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py
new file mode 100644
index 00000000..26ef8aea
--- /dev/null
+++ b/nicegui/persistence/redis_persistent_dict.py
@@ -0,0 +1,60 @@
+from .. import background_tasks, core, json, optional_features
+from ..logging import log
+from .persistent_dict import PersistentDict
+
+try:
+    import redis.asyncio as redis
+    optional_features.register('redis')
+except ImportError:
+    pass
+
+
+class RedisPersistentDict(PersistentDict):
+
+    def __init__(self, *, url: str, id: str, key_prefix: str = 'nicegui:') -> None:  # pylint: disable=redefined-builtin
+        if not optional_features.has('redis'):
+            raise ImportError('Redis is not installed. Please run "pip install nicegui[redis]".')
+        self.redis_client = redis.from_url(url)
+        self.pubsub = self.redis_client.pubsub()
+        self.key = key_prefix + id
+        super().__init__(data={}, on_change=self.publish)
+
+    async def initialize(self) -> None:
+        """Load initial data from Redis and start listening for changes."""
+        try:
+            data = await self.redis_client.get(self.key)
+            self.update(json.loads(data) if data else {})
+        except Exception:
+            log.warning(f'Could not load data from Redis with key {self.key}')
+        await self.pubsub.subscribe(self.key + 'changes')
+
+        async def listen():
+            async for message in self.pubsub.listen():
+                if message['type'] == 'message':
+                    new_data = json.loads(message['data'])
+                    if new_data != self:
+                        self.update(new_data)
+
+        background_tasks.create(listen(), name=f'redis-listen-{self.key}')
+
+    def publish(self) -> None:
+        """Publish the data to Redis and notify other instances."""
+        async def backup() -> None:
+            pipeline = self.redis_client.pipeline()
+            pipeline.set(self.key, json.dumps(self))
+            pipeline.publish(self.key + 'changes', json.dumps(self))
+            await pipeline.execute()
+        if core.loop:
+            background_tasks.create_lazy(backup(), name=f'redis-{self.key}')
+        else:
+            core.app.on_startup(backup())
+
+    async def close(self) -> None:
+        """Close Redis connection and subscription."""
+        await self.pubsub.unsubscribe()
+        await self.pubsub.close()
+        await self.redis_client.close()
+
+    def clear(self) -> None:
+        super().clear()
+        self.redis_client.delete(self.key)
diff --git a/nicegui/storage.py b/nicegui/storage.py
index a72b8c9d..5f701712 100644
--- a/nicegui/storage.py
+++ b/nicegui/storage.py
@@ -3,79 +3,26 @@ import contextvars
 import os
 import time
 import uuid
-from collections.abc import MutableMapping
 from datetime import timedelta
 from pathlib import Path
-from typing import Any, Dict, Iterator, Optional, Union
+from typing import Dict, Optional, Union
 
-import aiofiles
 from starlette.middleware import Middleware
 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
 from starlette.middleware.sessions import SessionMiddleware
 from starlette.requests import Request
 from starlette.responses import Response
 
-from . import background_tasks, core, json, observables
+from . import core, observables
 from .context import context
-from .logging import log
 from .observables import ObservableDict
+from .persistence import FilePersistentDict, PersistentDict, ReadOnlyDict, RedisPersistentDict
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
 PURGE_INTERVAL = timedelta(minutes=5).total_seconds()
 
 
-class ReadOnlyDict(MutableMapping):
-
-    def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None:
-        self._data: Dict[Any, Any] = data
-        self._write_error_message: str = write_error_message
-
-    def __getitem__(self, item: Any) -> Any:
-        return self._data[item]
-
-    def __setitem__(self, key: Any, value: Any) -> None:
-        raise TypeError(self._write_error_message)
-
-    def __delitem__(self, key: Any) -> None:
-        raise TypeError(self._write_error_message)
-
-    def __iter__(self) -> Iterator:
-        return iter(self._data)
-
-    def __len__(self) -> int:
-        return len(self._data)
-
-
-class PersistentDict(observables.ObservableDict):
-
-    def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None:
-        self.filepath = filepath
-        self.encoding = encoding
-        self.indent = indent
-        try:
-            data = json.loads(filepath.read_text(encoding)) if filepath.exists() else {}
-        except Exception:
-            log.warning(f'Could not load storage file {filepath}')
-            data = {}
-        super().__init__(data, on_change=self.backup)
-
-    def backup(self) -> None:
-        """Back up the data to the given file path."""
-        if not self.filepath.exists():
-            if not self:
-                return
-            self.filepath.parent.mkdir(exist_ok=True)
-
-        async def backup() -> None:
-            async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f:
-                await f.write(json.dumps(self, indent=self.indent))
-        if core.loop:
-            background_tasks.create_lazy(backup(), name=self.filepath.stem)
-        else:
-            core.app.on_startup(backup())
-
-
 class RequestTrackingMiddleware(BaseHTTPMiddleware):
 
     async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
@@ -83,6 +30,7 @@ class RequestTrackingMiddleware(BaseHTTPMiddleware):
         if 'id' not in request.session:
             request.session['id'] = str(uuid.uuid4())
         request.state.responded = False
+        await core.app.storage._create_user_storage(request.session['id'])  # pylint: disable=protected-access
         response = await call_next(request)
         request.state.responded = True
         return response
@@ -101,13 +49,31 @@ def set_storage_secret(storage_secret: Optional[str] = None) -> None:
 
 class Storage:
     secret: Optional[str] = None
+    """Secret key for session storage."""
+
+    path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
+    """Path to use for local persistence. Defaults to ".nicegui"."""
+
+    redis_url = os.environ.get('NICEGUI_REDIS_URL', None)
+    """URL to use for shared persistent storage via Redis. Defaults to None, which means local file storage is used."""
+
+    redis_key_prefix = os.environ.get('NICEGUI_REDIS_KEY_PREFIX', 'nicegui:')
+    """Prefix for Redis keys. Defaults to "nicegui:"."""
+
+    max_tab_storage_age: float = timedelta(days=30).total_seconds()
+    """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days."""
 
     def __init__(self) -> None:
-        self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
-        self.max_tab_storage_age = timedelta(days=30).total_seconds()
-        self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8')
+        self._general = Storage._create_persistent_dict('general')
         self._users: Dict[str, PersistentDict] = {}
-        self._tabs: Dict[str, observables.ObservableDict] = {}
+        self._tabs: Dict[str, PersistentDict] = {}
+
+    @staticmethod
+    def _create_persistent_dict(id: str) -> PersistentDict:  # pylint: disable=redefined-builtin
+        if Storage.redis_url:
+            return RedisPersistentDict(url=Storage.redis_url, id=id, key_prefix=Storage.redis_key_prefix)
+        else:
+            return FilePersistentDict(Storage.path / f'storage-{id}.json', encoding='utf-8')
 
     @property
     def browser(self) -> Union[ReadOnlyDict, Dict]:
@@ -136,7 +102,7 @@ class Storage:
     def user(self) -> PersistentDict:
         """Individual user storage that is persisted on the server (where NiceGUI is executed).
 
-        The data is stored in a file on the server.
+        The data is stored on the server.
         It is shared between all browser tabs by identifying the user via session cookie ID.
         """
         request: Optional[Request] = request_contextvar.get()
@@ -148,10 +114,14 @@ class Storage:
                 raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
             raise RuntimeError('app.storage.user can only be used within a UI context')
         session_id = request.session['id']
-        if session_id not in self._users:
-            self._users[session_id] = PersistentDict(self.path / f'storage-user-{session_id}.json', encoding='utf-8')
+        assert session_id in self._users, f'user storage for {session_id} should be created before accessing it'
         return self._users[session_id]
 
+    async def _create_user_storage(self, session_id: str) -> None:
+        if session_id not in self._users:
+            self._users[session_id] = Storage._create_persistent_dict(f'user-{session_id}')
+            await self._users[session_id].initialize()
+
     @staticmethod
     def _is_in_auto_index_context() -> bool:
         try:
@@ -187,20 +157,28 @@ class Storage:
             raise RuntimeError('app.storage.tab can only be used with a client connection; '
                                'see https://nicegui.io/documentation/page#wait_for_client_connection to await it')
         assert client.tab_id is not None
-        if client.tab_id not in self._tabs:
-            self._tabs[client.tab_id] = observables.ObservableDict()
+        assert client.tab_id in self._tabs, f'tab storage for {client.tab_id} should be created before accessing it'
         return self._tabs[client.tab_id]
 
+    async def _create_tab_storage(self, tab_id: str) -> None:
+        """Create tab storage for the given tab ID."""
+        if tab_id not in self._tabs:
+            self._tabs[tab_id] = Storage._create_persistent_dict(f'tab-{tab_id}')
+            await self._tabs[tab_id].initialize()
+
     def copy_tab(self, old_tab_id: str, tab_id: str) -> None:
         """Copy the tab storage to a new tab. (For internal use only.)"""
         if old_tab_id in self._tabs:
-            self._tabs[tab_id] = observables.ObservableDict(self._tabs[old_tab_id].copy())
+            self._tabs[tab_id] = Storage._create_persistent_dict(f'tab-{tab_id}')
+            self._tabs[tab_id].update(self._tabs[old_tab_id])
 
     async def prune_tab_storage(self) -> None:
         """Regularly prune tab storage that is older than the configured `max_tab_storage_age`."""
         while True:
             for tab_id, tab in list(self._tabs.items()):
                 if time.time() > tab.last_modified + self.max_tab_storage_age:
+                    tab.clear()
+                    await tab.close()
                     del self._tabs[tab_id]
             await asyncio.sleep(PURGE_INTERVAL)
 
@@ -219,3 +197,9 @@ class Storage:
             filepath.unlink()
         if self.path.exists():
             self.path.rmdir()
+
+    async def on_shutdown(self) -> None:
+        """Close all persistent storage."""
+        for user in self._users.values():
+            await user.close()
+        await self._general.close()
diff --git a/nicegui/ui_run.py b/nicegui/ui_run.py
index 708fd87b..4e7ce6ec 100644
--- a/nicegui/ui_run.py
+++ b/nicegui/ui_run.py
@@ -4,6 +4,7 @@ import sys
 from pathlib import Path
 from typing import Any, List, Literal, Optional, Tuple, TypedDict, Union
 
+from fastapi.middleware.gzip import GZipMiddleware
 from starlette.routing import Route
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
@@ -16,6 +17,7 @@ from .air import Air
 from .client import Client
 from .language import Language
 from .logging import log
+from .middlewares import RedirectWithPrefixMiddleware
 from .server import CustomServerConfig, Server
 
 APP_IMPORT_STRING = 'nicegui:app'
@@ -88,7 +90,7 @@ def run(*,
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
     :param message_history_length: maximum number of messages that will be stored and resent after a connection interruption (default: 1000, use 0 to disable)
-    :param fastapi_docs: enable FastAPI's automatic documentation with Swagger UI, ReDoc, and OpenAPI JSON (bool or dictionary as described `here<https://fastapi.tiangolo.com/tutorial/metadata/>`_, default: `False`)
+    :param fastapi_docs: enable FastAPI's automatic documentation with Swagger UI, ReDoc, and OpenAPI JSON (bool or dictionary as described `here <https://fastapi.tiangolo.com/tutorial/metadata/>`_, default: `False`)
     :param show: automatically open the UI in a browser tab (default: `True`)
     :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation/section_configuration_deployment#nicegui_on_air>`_ if set to `True` (default: disabled)
     :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
@@ -122,6 +124,9 @@ def run(*,
         show_welcome_message=show_welcome_message,
     )
     core.app.config.endpoint_documentation = endpoint_documentation
+    if not helpers.is_pytest():
+        core.app.add_middleware(GZipMiddleware)
+    core.app.add_middleware(RedirectWithPrefixMiddleware)
 
     for route in core.app.routes:
         if not isinstance(route, Route):
diff --git a/nicegui/ui_run_with.py b/nicegui/ui_run_with.py
index a7a4e864..29d519a2 100644
--- a/nicegui/ui_run_with.py
+++ b/nicegui/ui_run_with.py
@@ -3,10 +3,12 @@ from pathlib import Path
 from typing import Literal, Optional, Union
 
 from fastapi import FastAPI
+from fastapi.middleware.gzip import GZipMiddleware
 
 from . import core, storage
 from .air import Air
 from .language import Language
+from .middlewares import RedirectWithPrefixMiddleware
 from .nicegui import _shutdown, _startup
 
 
@@ -59,8 +61,9 @@ def run_with(
         prod_js=prod_js,
         show_welcome_message=show_welcome_message,
     )
-
     storage.set_storage_secret(storage_secret)
+    core.app.add_middleware(GZipMiddleware)
+    core.app.add_middleware(RedirectWithPrefixMiddleware)
 
     if on_air:
         core.air = Air('' if on_air is True else on_air)
diff --git a/poetry.lock b/poetry.lock
index f83e83fc..ae004548 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1190,13 +1190,13 @@ files = [
 
 [[package]]
 name = "jinja2"
-version = "3.1.4"
+version = "3.1.5"
 description = "A very fast and expressive template engine."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"},
-    {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"},
+    {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"},
+    {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"},
 ]
 
 [package.dependencies]
@@ -2626,13 +2626,13 @@ selenium = ["snapshot-selenium"]
 
 [[package]]
 name = "pygments"
-version = "2.18.0"
+version = "2.19.1"
 description = "Pygments is a syntax highlighting package written in Python."
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"},
-    {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"},
+    {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"},
+    {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"},
 ]
 
 [package.extras]
@@ -3006,13 +3006,13 @@ cli = ["click (>=5.0)"]
 
 [[package]]
 name = "python-engineio"
-version = "4.11.1"
+version = "4.11.2"
 description = "Engine.IO server and client for Python"
 optional = false
 python-versions = ">=3.6"
 files = [
-    {file = "python_engineio-4.11.1-py3-none-any.whl", hash = "sha256:8ff9ec366724cd9b0fd92acf7a61b15ae923d28f37f842304adbd7f71b3d6672"},
-    {file = "python_engineio-4.11.1.tar.gz", hash = "sha256:ff8a23a843c223ec793835f1bcf584ff89ce0f1c2bcce37dffa6436c6fa74133"},
+    {file = "python_engineio-4.11.2-py3-none-any.whl", hash = "sha256:f0971ac4c65accc489154fe12efd88f53ca8caf04754c46a66e85f5102ef22ad"},
+    {file = "python_engineio-4.11.2.tar.gz", hash = "sha256:145bb0daceb904b4bb2d3eb2d93f7dbb7bb87a6a0c4f20a94cc8654dec977129"},
 ]
 
 [package.dependencies]
@@ -3036,13 +3036,13 @@ files = [
 
 [[package]]
 name = "python-socketio"
-version = "5.12.0"
+version = "5.12.1"
 description = "Socket.IO server and client for Python"
 optional = false
 python-versions = ">=3.8"
 files = [
-    {file = "python_socketio-5.12.0-py3-none-any.whl", hash = "sha256:50fe22fd2b0aa634df3e74489e42217b09af2fb22eee45f2c006df36d1d08cb9"},
-    {file = "python_socketio-5.12.0.tar.gz", hash = "sha256:39b55bff4ef6ac5c39b8bbc38fa61962e22e15349b038c1ca7ee2e18824e06dc"},
+    {file = "python_socketio-5.12.1-py3-none-any.whl", hash = "sha256:24a0ea7cfff0e021eb28c68edbf7914ee4111bdf030b95e4d250c4dc9af7a386"},
+    {file = "python_socketio-5.12.1.tar.gz", hash = "sha256:0299ff1f470b676c09c1bfab1dead25405077d227b2c13cf217a34dadc68ba9c"},
 ]
 
 [package.dependencies]
@@ -3199,6 +3199,24 @@ packaging = "*"
 [package.extras]
 test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"]
 
+[[package]]
+name = "redis"
+version = "5.2.1"
+description = "Python client for Redis database and key-value store"
+optional = true
+python-versions = ">=3.8"
+files = [
+    {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"},
+    {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"},
+]
+
+[package.dependencies]
+async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""}
+
+[package.extras]
+hiredis = ["hiredis (>=3.0.0)"]
+ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"]
+
 [[package]]
 name = "requests"
 version = "2.32.3"
@@ -3222,29 +3240,29 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 
 [[package]]
 name = "ruff"
-version = "0.8.1"
+version = "0.9.1"
 description = "An extremely fast Python linter and code formatter, written in Rust."
 optional = false
 python-versions = ">=3.7"
 files = [
-    {file = "ruff-0.8.1-py3-none-linux_armv6l.whl", hash = "sha256:fae0805bd514066f20309f6742f6ee7904a773eb9e6c17c45d6b1600ca65c9b5"},
-    {file = "ruff-0.8.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8a4f7385c2285c30f34b200ca5511fcc865f17578383db154e098150ce0a087"},
-    {file = "ruff-0.8.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd054486da0c53e41e0086e1730eb77d1f698154f910e0cd9e0d64274979a209"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2029b8c22da147c50ae577e621a5bfbc5d1fed75d86af53643d7a7aee1d23871"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2666520828dee7dfc7e47ee4ea0d928f40de72056d929a7c5292d95071d881d1"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:333c57013ef8c97a53892aa56042831c372e0bb1785ab7026187b7abd0135ad5"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:288326162804f34088ac007139488dcb43de590a5ccfec3166396530b58fb89d"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b12c39b9448632284561cbf4191aa1b005882acbc81900ffa9f9f471c8ff7e26"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:364e6674450cbac8e998f7b30639040c99d81dfb5bbc6dfad69bc7a8f916b3d1"},
-    {file = "ruff-0.8.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b22346f845fec132aa39cd29acb94451d030c10874408dbf776af3aaeb53284c"},
-    {file = "ruff-0.8.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2f2f7a7e7648a2bfe6ead4e0a16745db956da0e3a231ad443d2a66a105c04fa"},
-    {file = "ruff-0.8.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:adf314fc458374c25c5c4a4a9270c3e8a6a807b1bec018cfa2813d6546215540"},
-    {file = "ruff-0.8.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a885d68342a231b5ba4d30b8c6e1b1ee3a65cf37e3d29b3c74069cdf1ee1e3c9"},
-    {file = "ruff-0.8.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d2c16e3508c8cc73e96aa5127d0df8913d2290098f776416a4b157657bee44c5"},
-    {file = "ruff-0.8.1-py3-none-win32.whl", hash = "sha256:93335cd7c0eaedb44882d75a7acb7df4b77cd7cd0d2255c93b28791716e81790"},
-    {file = "ruff-0.8.1-py3-none-win_amd64.whl", hash = "sha256:2954cdbe8dfd8ab359d4a30cd971b589d335a44d444b6ca2cb3d1da21b75e4b6"},
-    {file = "ruff-0.8.1-py3-none-win_arm64.whl", hash = "sha256:55873cc1a473e5ac129d15eccb3c008c096b94809d693fc7053f588b67822737"},
-    {file = "ruff-0.8.1.tar.gz", hash = "sha256:3583db9a6450364ed5ca3f3b4225958b24f78178908d5c4bc0f46251ccca898f"},
+    {file = "ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743"},
+    {file = "ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f"},
+    {file = "ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b"},
+    {file = "ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831"},
+    {file = "ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab"},
+    {file = "ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1"},
+    {file = "ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366"},
+    {file = "ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f"},
+    {file = "ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72"},
+    {file = "ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19"},
+    {file = "ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7"},
+    {file = "ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17"},
 ]
 
 [[package]]
@@ -4151,9 +4169,10 @@ highcharts = ["nicegui-highcharts"]
 matplotlib = ["matplotlib"]
 native = ["pywebview"]
 plotly = ["plotly"]
+redis = ["redis"]
 sass = ["libsass"]
 
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.8"
-content-hash = "35e8fa566398a4abf0e9eb7dea6fa6b8cf613d9fb97736b95d00b65923cb9dbb"
+content-hash = "070f7b280683a2fec6486d3bc4908f3e647efdc970e11f703eb5311944499d72"
diff --git a/pyproject.toml b/pyproject.toml
index e95556e1..ffb9d7cb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "nicegui"
-version = "2.9.0-dev"
+version = "2.9.1-dev"
 description = "Create web-based user interfaces with Python. The nice way."
 authors = ["Zauberzeug GmbH <info@zauberzeug.com>"]
 license = "MIT"
@@ -18,7 +18,7 @@ fastapi = ">=0.109.1"
 python-socketio = {extras = ["asyncio-client"], version = ">=5.10.0"} # version min: see https://github.com/zauberzeug/nicegui/issues/1809
 vbuild = ">=0.8.2"
 watchfiles = ">=0.18.1"
-jinja2 = "^3.1.4" # https://github.com/zauberzeug/nicegui/security/dependabot/32
+jinja2 = "^3.1.5" # https://github.com/zauberzeug/nicegui/security/dependabot/43
 python-multipart = ">=0.0.18"
 orjson = {version = ">=3.9.15", markers = "platform_machine != 'i386' and platform_machine != 'i686'"} # https://github.com/zauberzeug/nicegui/security/dependabot/29, orjson does not support 32bit
 itsdangerous = "^2.1.2"
@@ -35,6 +35,7 @@ docutils = ">=0.19.0"
 requests = ">=2.32.0" # https://github.com/zauberzeug/nicegui/security/dependabot/33
 urllib3 = ">=1.26.18,!=2.0.0,!=2.0.1,!=2.0.2,!=2.0.3,!=2.0.4,!=2.0.5,!=2.0.6,!=2.0.7,!=2.1.0,!=2.2.0,!=2.2.1" # https://github.com/zauberzeug/nicegui/security/dependabot/34
 certifi = ">=2024.07.04" # https://github.com/zauberzeug/nicegui/security/dependabot/35
+redis = { version = ">=4.0.0", optional = true }
 
 [tool.poetry.extras]
 native = ["pywebview"]
@@ -42,7 +43,7 @@ plotly = ["plotly"]
 matplotlib = ["matplotlib"]
 highcharts = ["nicegui-highcharts"]
 sass = ["libsass"]
-
+redis = ["redis"]
 [tool.poetry.group.dev.dependencies]
 autopep8 = ">=1.5.7,<3.0.0"
 debugpy = "^1.3.0"
diff --git a/release.dockerfile b/release.dockerfile
index 6e9c4020..dfdcfac9 100644
--- a/release.dockerfile
+++ b/release.dockerfile
@@ -27,7 +27,8 @@ RUN python -m pip install \
     pytest \
     requests \
     latex2mathml \
-    selenium
+    selenium \
+    redis
 
 WORKDIR /app
 
diff --git a/website/documentation/content/clipboard_documentation.py b/website/documentation/content/clipboard_documentation.py
index 37cb05f1..0eb1d83f 100644
--- a/website/documentation/content/clipboard_documentation.py
+++ b/website/documentation/content/clipboard_documentation.py
@@ -4,7 +4,7 @@ from . import doc
 
 
 @doc.demo('Read and write to the clipboard', '''
-    The following demo shows how to use `ui.clipboard.read()` and `ui.clipboard.write()` to interact with the clipboard.
+    The following demo shows how to use `ui.clipboard.read()`, `ui.clipboard.write()` and `ui.clipboard.read_image()` to interact with the clipboard.
 
     Because auto-index page can be accessed by multiple browser tabs simultaneously, reading the clipboard is not supported on this page.
     This is only possible within page-builder functions decorated with `ui.page`, as shown in this demo.
@@ -15,11 +15,20 @@ def main_demo() -> None:
     # @ui.page('/')
     # async def index():
     with ui.column():  # HIDE
-        ui.button('Write', on_click=lambda: ui.clipboard.write('Hi!'))
+        ui.button('Write Text', on_click=lambda: ui.clipboard.write('Hi!'))
 
         async def read() -> None:
             ui.notify(await ui.clipboard.read())
-        ui.button('Read', on_click=read)
+        ui.button('Read Text', on_click=read)
+
+        async def read_image() -> None:
+            img = await ui.clipboard.read_image()
+            if not img:
+                ui.notify('You must copy an image to clipboard first.')
+            else:
+                image.set_source(img)
+        ui.button('Read Image', on_click=read_image)
+        image = ui.image().classes('w-72')
 
 
 @doc.demo('Client-side clipboard', '''
diff --git a/website/documentation/content/dialog_documentation.py b/website/documentation/content/dialog_documentation.py
index b2ed76d3..ad7b09b1 100644
--- a/website/documentation/content/dialog_documentation.py
+++ b/website/documentation/content/dialog_documentation.py
@@ -48,4 +48,18 @@ def replace_content():
     ui.button('Replace', on_click=replace)
 
 
+@doc.demo('Events', '''
+    Dialogs emit events when they are opened or closed.
+    See the [Quasar documentation](https://quasar.dev/vue-components/dialog) for more information.
+''')
+def events():
+    with ui.dialog().props('backdrop-filter="blur(8px) brightness(40%)"') as dialog:
+        ui.label('Press ESC to close').classes('text-3xl text-white')
+
+    dialog.on('show', lambda: ui.notify('Dialog opened'))
+    dialog.on('hide', lambda: ui.notify('Dialog closed'))
+    dialog.on('escape-key', lambda: ui.notify('ESC pressed'))
+    ui.button('Open', on_click=dialog.open)
+
+
 doc.reference(ui.dialog)
diff --git a/website/documentation/content/doc/api.py b/website/documentation/content/doc/api.py
index b08b08e7..58bd54c6 100644
--- a/website/documentation/content/doc/api.py
+++ b/website/documentation/content/doc/api.py
@@ -101,7 +101,7 @@ def demo(*args, **kwargs) -> Callable[[Callable], Callable]:
             app_name = _find_attribute(nicegui_app, name)
             if ui_name:
                 page.title = f'ui.*{ui_name}*'
-            if app_name:
+            elif app_name:
                 page.title = f'app.*{app_name}*'
         page.parts.append(DocumentationPart(
             title=title_,
diff --git a/website/documentation/content/line_plot_documentation.py b/website/documentation/content/line_plot_documentation.py
index c339bb09..6d1538c0 100644
--- a/website/documentation/content/line_plot_documentation.py
+++ b/website/documentation/content/line_plot_documentation.py
@@ -16,7 +16,7 @@ def main_demo() -> None:
         x = now.timestamp()
         y1 = math.sin(x)
         y2 = math.cos(x)
-        line_plot.push([now], [[y1], [y2]])
+        line_plot.push([now], [[y1], [y2]], y_limits=(-1.5, 1.5))
 
     line_updates = ui.timer(0.1, update_line_plot, active=False)
     line_checkbox = ui.checkbox('active').bind_value(line_updates, 'active')
diff --git a/website/documentation/content/overview.py b/website/documentation/content/overview.py
index 656d5df3..1ca72a6a 100644
--- a/website/documentation/content/overview.py
+++ b/website/documentation/content/overview.py
@@ -14,6 +14,7 @@ from . import (
     section_testing,
     section_text_elements,
 )
+from ...style import subheading
 
 doc.title('*NiceGUI* Documentation', 'Reference, Demos and more')
 
@@ -151,3 +152,318 @@ def create_tiles():
             if page.title:
                 ui.label(page.title.replace('*', '')).classes(replace='text-2xl')
             ui.markdown(description).classes(replace='bold-links arrow-links')
+
+
+@doc.ui
+def map_of_nicegui():
+    ui.separator().classes('mt-6')
+    subheading('Map of NiceGUI', anchor_name='map-of-nicegui')
+    ui.add_css('''
+        .map-of-nicegui a code {
+            font-weight: bold;
+        }
+    ''')
+    ui.markdown('''
+        This overview shows the structure of NiceGUI.
+        It is a map of the NiceGUI namespace and its contents.
+        It is not exhaustive, but it gives you a good idea of what is available.
+        An ongoing goal is to make this map more complete and to add missing links to the documentation.
+
+        #### `ui`
+
+        UI elements and other essentials to run a NiceGUI app.
+
+        - [`ui.element`](/documentation/element): base class for all UI elements
+            - customization:
+                - `.props()` and [`.default_props()`](/documentation/element#default_props): add Quasar props and regular HTML attributes
+                - `.classes()` and [`.default_classes()`](/documentation/element#default_classes): add Quasar, Tailwind and custom HTML classes
+                - [`.tailwind`](/documentation/section_styling_appearance#tailwind_css): convenience API for adding Tailwind classes
+                - `.style()` and [`.default_style()`](/documentation/element#default_style): add CSS style definitions
+                - [`.tooltip()`](/documentation/tooltip): add a tooltip to an element
+                - [`.mark()`](/documentation/element_filter#markers): mark an element for querying with an [ElementFilter](/documentation/element_filter)
+            - interaction:
+                - [`.on()`](/documentation/generic_events): add Python and JavaScript event handlers
+                - `.update()`: send an update to the client (mostly done automatically)
+                - `.run_method()`: run a method on the client side
+                - `.get_computed_prop()`: get the value of a property that is computed on the client side
+            - hierarchy:
+                - `with ...:` nesting elements in a declarative way
+                - `__iter__`: an iterator over all child elements
+                - `ancestors`: an iterator over the element's parent, grandparent, etc.
+                - `descendants`: an iterator over all child elements, grandchildren, etc.
+                - `slots`: a dictionary of named slots
+                - `add_slot`: fill a new slot with NiceGUI elements or a scoped slot with template strings
+                - [`clear`](/documentation/section_page_layout#clear_containers): remove all child elements
+                - [`move`](/documentation/element#move_elements): move an element to a new parent
+                - `remove`: remove a child element
+                - `delete`: delete an element and all its children
+                - `is_deleted`: whether an element has been deleted
+        - elements:
+            - [`ui.aggrid`](/documentation/aggrid)
+            - [`ui.audio`](/documentation/audio)
+            - [`ui.avatar`](/documentation/avatar)
+            - [`ui.badge`](/documentation/badge)
+            - [`ui.button`](/documentation/button)
+            - [`ui.button_group`](/documentation/button_group)
+            - [`ui.card`](/documentation/card), `ui.card_actions`, `ui.card_section`
+            - [`ui.carousel`](/documentation/carousel), `ui.carousel_slide`
+            - [`ui.chat_message`](/documentation/chat_message)
+            - [`ui.checkbox`](/documentation/checkbox)
+            - [`ui.chip`](/documentation/chip)
+            - [`ui.circular_progress`](/documentation/circular_progress)
+            - [`ui.code`](/documentation/code)
+            - [`ui.codemirror`](/documentation/codemirror)
+            - [`ui.color_input`](/documentation/color_input)
+            - [`ui.color_picker`](/documentation/color_picker)
+            - [`ui.column`](/documentation/column)
+            - [`ui.context_menu`](/documentation/context_menu)
+            - [`ui.date`](/documentation/date)
+            - [`ui.dialog`](/documentation/dialog)
+            - [`ui.dropdown_button`](/documentation/button_dropdown)
+            - [`ui.echart`](/documentation/echart)
+            - [`ui.editor`](/documentation/editor)
+            - [`ui.expansion`](/documentation/expansion)
+            - [`ui.grid`](/documentation/grid)
+            - [`ui.highchart`](/documentation/highchart)
+            - [`ui.html`](/documentation/html)
+            - [`ui.icon`](/documentation/icon)
+            - [`ui.image`](/documentation/image)
+            - [`ui.input`](/documentation/input)
+            - [`ui.interactive_image`](/documentation/interactive_image)
+            - `ui.item`, `ui.item_label`, `ui.item_section`
+            - [`ui.joystick`](/documentation/joystick)
+            - [`ui.json_editor`](/documentation/json_editor)
+            - [`ui.knob`](/documentation/knob)
+            - [`ui.label`](/documentation/label)
+            - [`ui.leaflet`](/documentation/leaflet)
+            - [`ui.line_plot`](/documentation/line_plot)
+            - [`ui.linear_progress`](/documentation/linear_progress)
+            - [`ui.link`](/documentation/link), `ui.link_target`
+            - [`ui.list`](/documentation/list)
+            - [`ui.log`](/documentation/log)
+            - [`ui.markdown`](/documentation/markdown)
+            - [`ui.matplotlib`](/documentation/matplotlib)
+            - [`ui.menu`](/documentation/menu), `ui.menu_item`
+            - [`ui.mermaid`](/documentation/mermaid)
+            - [`ui.notification`](/documentation/notification)
+            - [`ui.number`](/documentation/number)
+            - [`ui.pagination`](/documentation/pagination)
+            - [`ui.plotly`](/documentation/plotly)
+            - [`ui.pyplot`](/documentation/pyplot)
+            - [`ui.radio`](/documentation/radio)
+            - [`ui.range`](/documentation/range)
+            - [`ui.restructured_text`](/documentation/restructured_text)
+            - [`ui.row`](/documentation/row)
+            - [`ui.scene`](/documentation/scene), [`ui.scene_view`](/documentation/scene#scene_view)
+            - [`ui.scroll_area`](/documentation/scroll_area)
+            - [`ui.select`](/documentation/select)
+            - [`ui.separator`](/documentation/separator)
+            - [`ui.skeleton`](/documentation/skeleton)
+            - [`ui.slider`](/documentation/slider)
+            - [`ui.space`](/documentation/space)
+            - [`ui.spinner`](/documentation/spinner)
+            - [`ui.splitter`](/documentation/splitter)
+            - [`ui.stepper`](/documentation/stepper), `ui.step`, `ui.stepper_navigation`
+            - [`ui.switch`](/documentation/switch)
+            - [`ui.tabs`](/documentation/tabs), `ui.tab`, `ui.tab_panels`, `ui.tab_panel`
+            - [`ui.table`](/documentation/table)
+            - [`ui.textarea`](/documentation/textarea)
+            - [`ui.time`](/documentation/time)
+            - [`ui.timeline`](/documentation/timeline), `ui.timeline_entry`
+            - [`ui.toggle`](/documentation/toggle)
+            - [`ui.tooltip`](/documentation/tooltip)
+            - [`ui.tree`](/documentation/tree)
+            - [`ui.upload`](/documentation/upload)
+            - [`ui.video`](/documentation/video)
+        - special layout [elements](/documentation/page_layout):
+            - `ui.header`
+            - `ui.footer`
+            - `ui.drawer`, `ui.left_drawer`, `ui.right_drawer`
+            - `ui.page_sticky`
+        - special functions and objects:
+            - [`ui.add_body_html`](/documentation/section_pages_routing#add_html_to_the_page) and
+                [`ui.add_head_html`](/documentation/section_pages_routing#add_html_to_the_page): add HTML to the body and head of the page
+            - [`ui.add_css`](/documentation/add_style#add_css_style_definitions_to_the_page),
+                [`ui.add_sass`](/documentation/add_style#add_sass_style_definitions_to_the_page) and
+                [`ui.add_scss`](/documentation/add_style#add_scss_style_definitions_to_the_page): add CSS, SASS and SCSS to the page
+            - [`ui.clipboard`](/documentation/clipboard): interact with the browser's clipboard
+            - [`ui.colors`](/documentation/colors): define the main color theme for a page
+            - `ui.context`: get the current UI context including the `client` and `request` objects
+            - [`ui.dark_mode`](/documentation/dark_mode): get and set the dark mode on a page
+            - [`ui.download`](/documentation/download): download a file to the client
+            - [`ui.keyboard`](/documentation/keyboard): define keyboard event handlers
+            - [`ui.navigate`](/documentation/navigate): let the browser navigate to another location
+            - [`ui.notify`](/documentation/notification): show a notification
+            - [`ui.on`](/documentation/generic_events#custom_events): register an event handler
+            - [`ui.page_title`](/documentation/page_title): change the current page title
+            - [`ui.query`](/documentation/query): query HTML elements on the client side to modify props, classes and style definitions
+            - [`ui.run`](/documentation/run) and `ui.run_with`: run the app (standalone or attached to a FastAPI app)
+            - [`ui.run_javascript`](/documentation/run#run_custom_javascript_on_the_client_side): run custom JavaScript on the client side (can use `getElement()`, `getHtmlElement()`, and `emitEvent()`)
+            - [`ui.teleport`](/documentation/teleport): teleport an element to a different location in the HTML DOM
+            - [`ui.timer`](/documentation/timer): run a function periodically or once after a delay
+            - `ui.update`: send updates of multiple elements to the client
+        - decorators:
+            - [`ui.page`](/documentation/page): define a page (in contrast to the automatically generated "auto-index page")
+            - [`ui.refreshable`](/documentation/refreshable), `ui.refreshable_method`: define refreshable UI containers
+                (can use [`ui.state`](/documentation/refreshable#refreshable_ui_with_reactive_state))
+
+        #### `app`
+
+        App-wide storage, mount points and lifecycle hooks.
+
+        - [`app.storage`](/documentation/storage):
+            - `app.storage.tab`: stored in memory on the server, unique per tab
+            - `app.storage.client`: stored in memory on the server, unique per client connected to a page
+            - `app.storage.user`: stored in a file on the server, unique per browser
+            - `app.storage.general`: stored in a file on the server, shared across the entire app
+            - `app.storage.browser`: stored in the browser's local storage, unique per browser
+        - [lifecycle hooks](/documentation/section_action_events#events):
+            - `app.on_connect()`: called when a client connects
+            - `app.on_disconnect()`: called when a client disconnects
+            - `app.on_startup()`: called when the app starts
+            - `app.on_shutdown()`: called when the app shuts down
+            - `app.on_exception()`: called when an exception occurs
+        - [`app.shutdown()`](/documentation/section_action_events#shut_down_nicegui): shut down the app
+        - static files:
+            - [`app.add_static_files()`](/documentation/section_pages_routing#add_a_directory_of_static_files),
+                `app.add_static_file()`: serve static files
+            - [`app.add_media_files()`](/documentation/section_pages_routing#add_directory_of_media_files),
+                `app.add_media_file()`: serve media files (supports streaming)
+        - [`app.native`](/documentation/section_configuration_deployment#native_mode): configure the app when running in native mode
+
+        #### `html`
+
+        [Pure HTML elements](/documentation/html#other_html_elements):
+
+        `a`,
+        `abbr`,
+        `acronym`,
+        `address`,
+        `area`,
+        `article`,
+        `aside`,
+        `audio`,
+        `b`,
+        `basefont`,
+        `bdi`,
+        `bdo`,
+        `big`,
+        `blockquote`,
+        `br`,
+        `button`,
+        `canvas`,
+        `caption`,
+        `cite`,
+        `code`,
+        `col`,
+        `colgroup`,
+        `data`,
+        `datalist`,
+        `dd`,
+        `del_`,
+        `details`,
+        `dfn`,
+        `dialog`,
+        `div`,
+        `dl`,
+        `dt`,
+        `em`,
+        `embed`,
+        `fieldset`,
+        `figcaption`,
+        `figure`,
+        `footer`,
+        `form`,
+        `h1`,
+        `header`,
+        `hgroup`,
+        `hr`,
+        `i`,
+        `iframe`,
+        `img`,
+        `input_`,
+        `ins`,
+        `kbd`,
+        `label`,
+        `legend`,
+        `li`,
+        `main`,
+        `map_`,
+        `mark`,
+        `menu`,
+        `meter`,
+        `nav`,
+        `object_`,
+        `ol`,
+        `optgroup`,
+        `option`,
+        `output`,
+        `p`,
+        `param`,
+        `picture`,
+        `pre`,
+        `progress`,
+        `q`,
+        `rp`,
+        `rt`,
+        `ruby`,
+        `s`,
+        `samp`,
+        `search`,
+        `section`,
+        `select`,
+        `small`,
+        `source`,
+        `span`,
+        `strong`,
+        `sub`,
+        `summary`,
+        `sup`,
+        `svg`,
+        `table`,
+        `tbody`,
+        `td`,
+        `template`,
+        `textarea`,
+        `tfoot`,
+        `th`,
+        `thead`,
+        `time`,
+        `tr`,
+        `track`,
+        `u`,
+        `ul`,
+        `var`,
+        `video`,
+        `wbr`
+
+        #### `background_tasks`
+
+        Run async functions in the background.
+
+        - `create()`: create a background task
+        - `create_lazy()`: prevent two tasks with the same name from running at the same time
+
+        #### `run`
+
+        Run IO and CPU bound functions in separate threads and processes.
+
+        - [`run.cpu_bound()`](/documentation/section_action_events#running_cpu-bound_tasks): run a CPU-bound function in a separate process
+        - [`run.io_bound()`](/documentation/section_action_events#running_i_o-bound_tasks): run an IO-bound function in a separate thread
+
+        #### `observables`
+
+        Observable collections that notify observers when their contents change.
+
+        - `ObservableCollection`: base class
+        - `ObservableDict`: an observable dictionary
+        - `ObservableList`: an observable list
+        - `ObservableSet`: an observable set
+
+        #### `testing`
+
+        Write automated UI tests which run in a headless browser (slow) or fully simulated in Python (fast).
+
+        - [`Screen`](/documentation/section_testing#screen_fixture) fixture: start a real (headless) browser to interact with your application
+        - [`User`](/documentation/section_testing#user_fixture) fixture: simulate user interaction on a Python level (fast)
+    ''').classes('map-of-nicegui arrow-links bold-links')
diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py
index 7b012d69..71b1759d 100644
--- a/website/documentation/content/section_configuration_deployment.py
+++ b/website/documentation/content/section_configuration_deployment.py
@@ -72,6 +72,8 @@ doc.text('', '''
     - `NICEGUI_STORAGE_PATH` (default: local ".nicegui") can be set to change the location of the storage files.
     - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory.
     - `RST_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of ReStructuredText content snippets that are cached in memory.
+    - `NICEGUI_REDIS_URL` (default: None, means local file storage): The URL of the Redis server to use for shared persistent storage.
+    - `NICEGUI_REDIS_KEY_PREFIX` (default: "nicegui:"): The prefix for Redis keys.
 ''')
 def env_var_demo():
     from nicegui.elements import markdown
diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py
index 9a29faae..4dfe0428 100644
--- a/website/documentation/content/storage_documentation.py
+++ b/website/documentation/content/storage_documentation.py
@@ -45,18 +45,18 @@ doc.title('Storage')
 
     The following table will help you to choose storage.
 
-    | Storage type                | `tab`  | `client` | `user` | `general` | `browser` |
-    |-----------------------------|--------|----------|--------|-----------|-----------|
-    | Location                    | Server | Server   | Server | Server    | Browser   |
-    | Across tabs                 | No     | No       | Yes    | Yes       | Yes       |
-    | Across browsers             | No     | No       | No     | Yes       | No        |
-    | Across server restarts      | No     | No       | No     | Yes       | No        |
-    | Across page reloads         | Yes    | No       | Yes    | Yes       | Yes       |
-    | Needs page builder function | Yes    | Yes      | Yes    | No        | Yes       |
-    | Needs client connection     | Yes    | No       | No     | No        | No        |
-    | Write only before response  | No     | No       | No     | No        | Yes       |
-    | Needs serializable data     | No     | No       | Yes    | Yes       | Yes       |
-    | Needs `storage_secret`      | No     | No       | Yes    | No        | Yes       |
+    | Storage type                | `client` | `tab`  | `browser` | `user` | `general` |
+    |-----------------------------|----------|--------|-----------|--------|-----------|
+    | Location                    | Server   | Server | Browser   | Server | Server    |
+    | Across tabs                 | No       | No     | Yes       | Yes    | Yes       |
+    | Across browsers             | No       | No     | No        | No     | Yes       |
+    | Across server restarts      | No       | Yes    | No        | Yes    | Yes       |
+    | Across page reloads         | No       | Yes    | Yes       | Yes    | Yes       |
+    | Needs page builder function | Yes      | Yes    | Yes       | Yes    | No        |
+    | Needs client connection     | No       | Yes    | No        | No     | No        |
+    | Write only before response  | No       | No     | Yes       | No     | No        |
+    | Needs serializable data     | No       | No     | Yes       | Yes    | Yes       |
+    | Needs `storage_secret`      | No       | No     | Yes       | Yes    | No        |
 ''')
 def storage_demo():
     from nicegui import app
@@ -132,6 +132,22 @@ def tab_storage():
         ui.button('Reload page', on_click=ui.navigate.reload)
 
 
+@doc.demo('Maximum age of tab storage', '''
+    By default, the tab storage is kept for 30 days.
+    You can change this by setting `app.storage.max_tab_storage_age`.
+''')
+def max_tab_storage_age():
+    from nicegui import app
+    from datetime import timedelta
+    # app.storage.max_tab_storage_age = timedelta(minutes=1).total_seconds()
+    ui.label(f'Tab storage age: {timedelta(minutes=1).total_seconds()} seconds')  # HIDE
+
+    @ui.page('/')
+    def index():
+        # ui.label(f'Tab storage age: {app.storage.max_tab_storage_age} seconds')
+        pass  # HIDE
+
+
 @doc.demo('Short-term memory', '''
     The goal of `app.storage.client` is to store data only for the duration of the current page visit.
     In difference to data stored in `app.storage.tab`
@@ -162,3 +178,24 @@ doc.text('Indentation', '''
     You can change this to an indentation of 2 spaces by setting
     `app.storage.general.indent = True` or `app.storage.user.indent = True`.
 ''')
+
+
+doc.text('Redis storage', '''
+    You can use [Redis](https://redis.io/) for storage as an alternative to the default file storage.
+    This is useful if you have multiple NiceGUI instances and want to share data across them.
+
+    To activate this feature install the `redis` package (`pip install nicegui[redis]`)
+    and provide the `NICEGUI_REDIS_URL` environment variable to point to your Redis server.
+    Our [Redis storage example](https://github.com/zauberzeug/nicegui/tree/main/examples/redis_storage) shows
+    how you can setup it up with a reverse proxy or load balancer.
+
+    Please note that the Redis sync always contains all the data, not only the changed values.
+
+    - For `app.storage.general` this is the whole dictionary.
+    - For `app.storage.user` it's all the data of the user.
+    - For `app.storage.tab` it's all the data stored for this specific tab.
+
+    If you have large data sets, we suggest to use a database instead.
+    See our [database example](https://github.com/zauberzeug/nicegui/blob/main/examples/sqlite_database/main.py) for a demo with SQLite.
+    But of course to sync between multiple instances you should replace SQLite with PostgreSQL or similar.
+''')
diff --git a/website/examples.py b/website/examples.py
index a95aec98..6362f4bd 100644
--- a/website/examples.py
+++ b/website/examples.py
@@ -69,4 +69,6 @@ examples: List[Example] = [
     Example('NGINX HTTPS', 'Use NGINX to serve a NiceGUI app with HTTPS'),
     Example('Node Module Integration', 'Use NPM to add dependencies to a NiceGUI app'),
     Example('Signature Pad', 'A custom element based on [signature_pad](https://www.npmjs.com/package/signature_pad'),
+    Example('OpenAI Assistant', "Using OpenAI's Assistant API with async/await"),
+    Example('Redis Storage', 'Use Redis storage to share data across multiple instances behind a reverse proxy or load balancer'),
 ]


Test patch:
diff --git a/test_startup.sh b/test_startup.sh
index cb4c2b30..ff3c8bd1 100755
--- a/test_startup.sh
+++ b/test_startup.sh
@@ -39,6 +39,11 @@ check() {
 check main.py || exit 1
 for path in examples/*
 do
+    # Skip examples/generate_pdf
+    if [[ $path == "examples/generate_pdf" ]]; then
+        continue # until https://github.com/pygobject/pycairo/issues/387 is fixed
+    fi
+
     # Skip examples/sqlite_database for Python 3.11 and 3.12
     if [[ $(python3 --version | cut -d' ' -f2 | cut -d'.' -f1,2) =~ ^3.1[12]$ ]] && [[ $path == "examples/sqlite_database" ]]; then
         continue # until https://github.com/omnilib/aiosqlite/issues/241 is fixed
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 55ad0a70..e24efef2 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -87,3 +87,12 @@ def test_is_file():
     assert not helpers.is_file(None)
     assert not helpers.is_file('x' * 100_000), 'a very long filepath should not lead to OSError 63'
     assert not helpers.is_file('https://nicegui.io/logo.png')
+
+
+def test_event_type_to_camel_case():
+    assert helpers.event_type_to_camel_case('click') == 'click'
+    assert helpers.event_type_to_camel_case('row-click') == 'rowClick'
+    assert helpers.event_type_to_camel_case('update:model-value') == 'update:modelValue'
+    assert helpers.event_type_to_camel_case('keydown.enter') == 'keydown.enter'
+    assert helpers.event_type_to_camel_case('keydown.+') == 'keydown.+'
+    assert helpers.event_type_to_camel_case('keydown.-') == 'keydown.-'