Widget Bundle Caching

Widget bundles are JavaScript IIFE packages fetched from the backend via graphql?op=GetWidgetBundle. Each bundle can be tens to hundreds of kilobytes. On slow or high-latency networks (VPN, remote sites), transferring every bundle on every page load is a significant bottleneck.

This page describes the IndexedDB-based cache that eliminates redundant bundle transfers while guaranteeing that the browser always runs the version the server currently has installed.

Integrity-Based Cache Model

The cache does not use time-to-live expiry. Instead it uses the integrity field β€” a SHA-256 hash of the bundle bytes β€” that the backend computes and stores in MongoDB when a widget is first downloaded or upgraded.

The same hash is returned by both:

  • LIST_WIDGETS β€” a lightweight metadata query, always fetched with fetchPolicy: 'no-cache' at every application boot

  • GetWidgetBundle β€” the full bundle query that includes the JS code

Because LIST_WIDGETS is always fresh, the browser always knows the server’s current integrity hash before it decides whether to fetch a bundle. A cached bundle is only used when its stored hash exactly matches the server’s current hash. Any mismatch (upgrade, redeploy, content change) is automatically a cache miss.

Boot Flow

        %%{init: {'theme': 'base', 'themeVariables': {'fontSize': '24px', 'fontFamily': 'Arial, sans-serif'}, 'flowchart': {'rankSpacing': 90, 'nodeSpacing': 80, 'padding': 30, 'htmlLabels': true}}}%%
flowchart TD
    classDef terminalStart fill:#f0f0f0,stroke:#6c757d,color:#333
    classDef terminalEnd   fill:#f0f0f0,stroke:#6c757d,color:#333
    classDef listNode      fill:#cce5ff,stroke:#004085,color:#004085
    classDef idbNode       fill:#e2d9f3,stroke:#6f42c1,color:#3d1a78
    classDef cacheHit      fill:#d4edda,stroke:#28a745,color:#155724
    classDef cacheMiss     fill:#fff3cd,stroke:#ffc107,color:#856404

    BOOT("<span style='display:inline-block;min-width:180px;padding:10px 0;font-size:18px;text-align:center'>Browser Boot</span>"):::terminalStart
    BOOT --> LIST

    LIST["LIST_WIDGETS<br/>always no-cache<br/>returns integrity hash per widget"]:::listNode
    LIST --> CHK

    CHK{"integrity<br/>returned?"}
    CHK -- No --> FETCH
    CHK -- Yes --> IDB

    IDB["IndexedDB<br/>.get(id)<br/>check local cache"]:::idbNode
    IDB --> MATCH

    MATCH{"stored hash<br/>= = =<br/>server hash?"}
    MATCH -- "βœ“ match" --> HIT
    MATCH -- "βœ— miss / stale" --> FETCH

    HIT["Execute cached code<br/>βœ“ zero network round-trip"]:::cacheHit
    HIT --> DONE

    FETCH["GetWidgetBundle<br/>fetch full bundle over network<br/>then store in IndexedDB"]:::cacheMiss
    FETCH --> DONE

    DONE("<span style='display:inline-block;min-width:220px;padding:12px 0;text-align:center'>Widget ready</span>"):::terminalEnd
    

Customising the diagram

Appearance (fill colour, border, text colour) β€” edit the classDef lines at the top of the diagram source. Each class maps to one node role.

Font size, width and height β€” classDef properties have no effect on layout with htmlLabels: true (labels render as HTML inside a <foreignObject>, not as SVG text). Use an inline <span> in the node label instead:

MYNODE("<span style='display:inline-block;min-width:200px;padding:14px 0;font-size:20px;text-align:center'>Label</span>"):::myClass

Span property

Controls

font-size

Text size for that node only

min-width

Minimum node width (width for exact)

padding: Npx 0

Top + bottom padding β†’ node height

Global spacing (gap between all nodes) β€” edit rankSpacing, nodeSpacing, and padding in the %%{init}%% line at the top of the diagram.

First Load (Cold Path)

When a widget has never been loaded in this browser, or its integrity hash has changed since it was last cached:

  1. LIST_WIDGETS returns the server’s current integrity for the widget.

  2. IndexedDB.get(id) returns nothing (or an entry with a different hash).

  3. GetWidgetBundle is called β€” the full bundle JS is fetched over the network.

  4. The bundle is executed immediately.

  5. IndexedDB.put({ id, integrity, code }) stores the bundle for future loads (fire-and-forget; a write failure only means the next load re-fetches).

Subsequent Loads (Warm Path)

When the widget is already cached and the server has not changed it:

  1. LIST_WIDGETS returns the same integrity hash as the cached entry.

  2. IndexedDB.get(id) returns the cached entry.

  3. The stored hash is compared to the server’s hash β€” they match.

  4. The cached code is executed directly. GetWidgetBundle is not called.

The large bundle payload never crosses the network. Only the compact LIST_WIDGETS response (metadata only, no code) is required.

Cache Invalidation

The cache entry for a widget is invalidated (becomes a miss) in two ways:

Trigger

Mechanism

Widget upgraded on the server

Backend changes integrity in MongoDB β†’ LIST_WIDGETS returns new hash β†’ hash mismatch β†’ cache miss β†’ re-fetch

Widget removed or marked stale in the frontend

markRemoteWidgetStale(id) clears the runtime loaded-set and calls IndexedDB.delete(id)

There is no manual cache invalidation step required after a widget upgrade. The integrity change propagates automatically on the next page load.

Security Properties

  • The integrity hash used for cache validation always comes from the server (LIST_WIDGETS is always no-cache). A browser-local cache entry cannot promote itself β€” it must match what the server declares.

  • A corrupted or tampered IndexedDB entry with a wrong hash will simply produce a cache miss and trigger a clean re-fetch.

  • The backend computes the integrity hash server-side (SHA-256 of the raw bundle bytes) and stores it in MongoDB. Clients cannot influence it.

Storage

Bundles are stored in an IndexedDB database named octopus-widget-cache, object store bundles, keyed by widget id. Each entry holds:

Field

Content

id

Stable backend widget ID (MongoDB ObjectId string)

integrity

SHA-256 hex hash of the bundle bytes

code

Full JS bundle source string

All IndexedDB operations are best-effort. Any failure (unavailable storage, quota exceeded, private-browsing restrictions) is caught silently and the system falls back to a normal GetWidgetBundle network fetch.