Charles Bochet 88b9294afd feat(front): persist metadata store cache in IndexedDB instead of localStorage (#21586)
## Problem

The metadata store cache (object/field metadata, views, page layouts,
command menu items, …) is persisted client-side to power **cache-first
boot**: the app renders instantly from the cache, then
`MinimalMetadataLoadEffect` revalidates per-collection hashes and only
refetches what's stale.

It was persisted to **localStorage**, which Safari/WebKit caps at **~5
MB per origin, counted in UTF-16 (2 bytes/char)** → an effective ceiling
of ~2.5 M characters. Measured on the seeded demo workspace (33 objects,
612 fields):

| Bucket | Safari quota (UTF-16) |
|---|---|
| `metadataStoreState__*` (26 keys) | **1.9 MB — 37%** |
| Whole origin | **2.47 MB — 48%** |

A workspace ~2.5× the demo's schema blows past 5 MB, and there is **no
`QuotaExceededError` handling** — `setItem` throws and breaks the app.
This is what large-workspace users on Safari have been hitting.

## Fix

Move **only the metadata store** to **IndexedDB** (multi-GB, disk-based
quota), keeping a **fully synchronous read path** so the ~24 consumers
that read these atoms with `useAtomValue` never suspend. The auth/UI
atoms (incl. the synchronously-read `tokenPair`) stay on localStorage —
intentionally scoped.

- **`createIndexedDbBackedJotaiStorage.ts`** — a synchronous Jotai
storage facade backed by an in-memory map, hydrated once from IndexedDB
at boot and written through on every set. IndexedDB access uses the
**`idb-keyval`** library (by the IndexedDB spec co-author, ~0.6 KB)
rather than a hand-rolled wrapper. Each cache gets its own database +
BroadcastChannel (`twenty-front-<cacheName>`), so it's safely reusable.
Swallowed errors are surfaced via `logError`. When IndexedDB is
unavailable the cache stays in memory only (re-fetched each boot).
- **`createAtomFamilyState`** — gains an optional `storage` param;
`metadataStoreState` uses the IndexedDB-backed storage.
- **`index.tsx`** — awaits hydration before mounting so atoms
(`getOnInit: true`) read the persisted snapshot synchronously →
cache-first boot preserved.
- **No migration**: the facade does not touch localStorage at all.
Pre-existing localStorage snapshots are ignored — on first boot of the
new code the IndexedDB cache is empty and atoms re-fetch from the
network (a one-time reconnect). Old `metadataStoreState__*` localStorage
keys are left in place (cleared by the existing logout/reset cleanup);
new writes only ever go to IndexedDB.
- **Cross-tab sync**: the old localStorage atoms synced across tabs for
free via `storage` events; the IndexedDB facade had no equivalent, so a
schema change in one tab left others stale until reload. Restored by
implementing the Jotai storage `subscribe` contract over a
**`BroadcastChannel`** — writes broadcast to other tabs, which update
their in-memory map and notify `atomWithStorage` subscribers so mounted
atoms re-render live. (BroadcastChannel doesn't echo to the sender, so
no feedback loop; guarded for environments without it.)

## Why a synchronous facade (not async `atomWithStorage`)

Consumers use `useAtomValue` directly; an async storage would make the
atoms resolve to Promises and **suspend** every reader. The in-memory
facade keeps reads synchronous (zero ripple on consumers) and confines
the async part to a single bulk read at boot, which the existing
`MinimalMetadataGater` loader already covers.

## Tests

### Automated
- Unit test (10 cases) for the storage facade: synchronous read/write,
IndexedDB write-through, hydration from IndexedDB, `removeItem`/`clear`,
per-cache DB namespacing, persist-failure logging, in-memory-only
behaviour when IndexedDB is unavailable, distinguishing a stored
`undefined` from a missing key, and cross-tab subscriber registration.
- Existing metadata-store tests (`useIsLayoutCustomizationDirty`,
`useDefaultHomePagePath`) still pass.
- `nx typecheck twenty-front` and `nx lint:diff-with-main twenty-front`
clean.

### Manual (local seeded workspace, two tabs, Playwright)
Storage:
- After login the metadata cache lives in **IndexedDB (24 keys, ~945
KB)** and **localStorage drops 48% → 11%** of the Safari quota (the
remainder is `currentUserState` + auth, out of scope).
- Reload boots from the cache (no heavy refetch).

Scenarios:

| Scenario | Result |
|---|---|
| **Sign out** | auth cleared, redirect to sign-in, no leftover
localStorage, no errors |
| **Sign back in** | metadata `up-to-date`, company table renders, token
restored |
| **Add object** (`Gadget`) | write-through to IndexedDB; survives
reload via cache-first hydration |
| **Add view** (`QA Cross Tab View`, TABLE) | persisted to the `views`
collection (`up-to-date`) |
| **Two tabs open** | second tab boots cleanly from the shared IndexedDB
— no lock/crash under concurrent access |
| **Cross-tab live sync** | creating an object in tab A makes it appear
in tab B's open settings object list **without a reload** |

Verified by design (no regression):
- Runtime sign-out (`clearSession`) clears session keys and does a full
`window.location.assign` reload; the metadata-clearing path
(`resetJotaiStore`) is test-only, so there's no
async-`clear()`-vs-sign-in race. Metadata persisting across sign-out is
unchanged from the old localStorage behavior (it's schema, revalidated
by hash on next login).

## Notes / follow-ups (not in this PR)

- **IndexedDB query capabilities** are not used yet: the cache stores
one blob per collection (as it did in localStorage), so this is still a
pure key-value use (`idb-keyval`). If we later want to query individual
metadata records — e.g. fields by `objectMetadataId` via an
index/cursor, or partial hydration — that means record-level storage and
a richer wrapper (`idb` for a thin near-native layer, or **Dexie** for a
full query API + reactive `liveQuery` that could also replace the
BroadcastChannel sync).
- IndexedDB still has a (large) quota and Safari ITP eviction applies to
both stores — the cache-first design already tolerates eviction by
revalidating.
- Complementary "load less" wins remain: the denormalized per-field
`relation` block (~700 chars/field of pure duplication) and persisting
`currentUser.workspaceMembers` (the ~0.5 MB still in localStorage).

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21586?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-06-15 13:25:53 +00:00
2026-06-11 11:02:28 +02:00

Twenty logo

The #1 Open-Source CRM

Website · Documentation · Roadmap · Discord · Figma

Twenty banner


Why Twenty

Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.

Learn more about why we built Twenty


Installation

Cloud

The fastest way to get started. Sign up at twenty.com and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.

Build an app

Scaffold a new app with the Twenty CLI:

npx create-twenty-app my-app

Define objects, fields, and views as code:

import { defineObject, FieldType } from 'twenty-sdk/define';

export default defineObject({
  nameSingular: 'deal',
  namePlural: 'deals',
  labelSingular: 'Deal',
  labelPlural: 'Deals',
  fields: [
    { name: 'name', label: 'Name', type: FieldType.TEXT },
    { name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
    { name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
  ],
});

Then ship it to your workspace:

npx twenty app:publish --private

See the app development guide for objects, views, agents, and logic functions.

Self-hosting

Run Twenty on your own infrastructure with Docker Compose, or contribute locally via the local setup guide.



Everything you need

Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.

Want to go deeper? Read the User Guide for product walkthroughs, or the Documentation for developer reference.

Create your apps

Learn more about apps in doc

Stay on top with version control

Learn more about version control in doc

All the tools you need to build anything

Learn more about primitives in doc

Customize your layouts

Learn more about layouts in doc

AI agents and chats

Learn more about AI in doc

Plus all the tools of a good CRM

Learn more about CRM features in doc


Stack

Thanks

Greptile      Sentry      Crowdin

Thanks to these amazing services that we use and recommend for code review (Greptile), catching bugs (Sentry) and translating (Crowdin).

Join the Community

Star the repo · Discord · Feature requests · Releases · X · LinkedIn · Crowdin · Contribute

S
Description
The open alternative to Salesforce, designed for AI.
Readme AGPL-3.0 1.4 GiB
Languages
TypeScript 79.6%
MDX 17.3%
JavaScript 2.7%
Python 0.2%
SCSS 0.1%