src/core/packages/user-storage/README.mdx
A server-backed, per-user store for plugin state that should follow the user across browsers, devices, and cache clears. Plugins register keys at setup time with a Zod schema, a default value, and a scope ('space' or 'global'); reads on both server and browser are schema-validated, reactive, and — on the browser — synchronous at first paint via rendering injection.
The one-line rule of thumb: if it's per-user data that should survive a device switch or a cache clear, register it in User Storage. Otherwise, reach for one of the systems described under Adjacent systems.
User Storage exists because over 30 plugins were independently writing to localStorage for per-user state, each handling serialization, scoping, and validation differently. There was no canonical pattern for "small, schema-validated, per-user data that follows the user." The service fills exactly that gap; it does not replace localStorage, uiSettings, or ES User Profile, all of which remain the right choice for their respective use cases.
Per-user preferences that should follow the user across browsers and devices. Example use cases include:
Per-space user state where the same user may want different settings in different spaces is the natural fit for scope: 'space'. Cross-space user preferences fit scope: 'global'.
User Storage co-exists with several other per-user storage layers; pick the one that fits the data, not the one that's most familiar.
| If the data is… | Use | Why |
|---|---|---|
| Ephemeral UI state — panel widths, collapsed/expanded states, sort orders, view-mode toggles, tour completions, callout dismissals | localStorage | Trivially re-set; no real cost when lost. Doesn't need to follow the user across devices. |
| Identity / personalization that might follow the user across Elastic Cloud products — dark mode, avatar | ES User Profile | ES enforces per-user isolation; field set is small and stable. The right home for portable identity data, not for arbitrary plugin-owned state. |
| Space-wide or cluster-wide admin-set defaults, not per-user | Advanced Settings (uiSettings) | Admin-managed and shared by all users in a space or cluster; can be enforced via kibana.yml overrides. No user dimension — see Deciding between Advanced Settings and User Storage. |
| Shared / multi-user data | Saved Objects | Has its own visibility and sharing model. |
| Large blobs (>~few KB) | file service | Saved Objects are not blob storage. |
| Frequently-changing high-volume telemetry | analytics | Persisting every event in a per-user SO is the wrong shape. |
| Settings that must work for unauthenticated users | localStorage | User Storage requires a profile_uid; there is no anonymous fallback today. |
Both store configuration-like values, so the two are easy to confuse. The deciding question is who owns the value: an Advanced Setting (uiSettings) is a single value per space (or global) shared by everyone, whereas a User Storage key is owned independently by each user.
Reach for Advanced Settings when:
uiSettings.overrides in kibana.yml).Reach for User Storage when:
scope: 'global') or vary per space (scope: 'space').localStorage usage with a server-backed, schema-validated store that survives device switches and cache clears.Three differences are worth weighing before choosing User Storage:
uiSettings.overrides; admins cannot pin a value for all users. If you need an enforced default and a per-user override, you need both systems.profile_uid. Anonymous and API-key requests get defaults and cannot write; Advanced Settings have no such requirement.preload: true keys (see Preloaded values).Register a key in your plugin's server setup, then read and write it in the browser with the useUserStorage hook.
// server/plugin.ts
import { z } from '@kbn/zod/v4';
import type { Plugin, CoreSetup } from '@kbn/core/server';
export class MyPlugin implements Plugin {
public setup(core: CoreSetup) {
core.userStorage.register({
'myPlugin:tour-dismissed': {
schema: z.boolean(),
defaultValue: false,
scope: 'global',
},
});
}
public start() {}
}
// public — wrap your app once, then read/write anywhere beneath it
import { UserStorageProvider, useUserStorage } from '@kbn/core-user-storage-browser';
const App = ({ core }: { core: CoreStart }) => (
<UserStorageProvider userStorage={core.userStorage}>
<TourCallout />
</UserStorageProvider>
);
const TourCallout = () => {
const [dismissed, setDismissed] = useUserStorage<boolean>('myPlugin:tour-dismissed', false);
if (dismissed) return null;
return <button onClick={() => setDismissed(true)}>Got it</button>;
};
That's the whole loop: one server registration, one provider, one hook. The rest of this document is reference detail for scope, preloading, the server API, observables, and testing.
Registration. Every key must be registered at server setup with a Zod schema, a default value, and a scope. Reads of unregistered keys throw; writes of unregistered keys return 400. Reads always succeed: if no value is stored, the registered default is returned. Each key may only be registered once across all plugins — duplicate registrations throw at boot.
Scope. A key is scoped either to a single space or globally across spaces:
'space' — the value is stored per (profile_uid, space_id). Use this for state that is meaningfully per-space (e.g. per-solution side-nav customization).'global' — the value is stored per profile_uid, ignoring the active space. Use this for cross-space user preferences (e.g. a one-time tour dismissal).Once you pick a scope for a key, changing it later is a breaking change for users with existing values. Choose deliberately.
Schema validation. The Zod schema runs at three points:
defaultValue (catches drift between the default and the schema at boot).set, against the incoming value (rejects invalid writes with 400).get, against the stored value. If the stored value fails to parse — usually because the schema has been narrowed since the value was written — the registered default is returned and a warning is logged. You can therefore tighten a schema without writing a migration, but consumers should treat the resulting value reset as a possible UX outcome.Preloaded values. By default, browser-side reads are lazy: the cache starts empty for a given key and the first get(key) / get$(key) call fires a GET /internal/user_storage/{key} request to hydrate it. While the request is in-flight get(key) returns undefined (or the provided defaultValue) and get$(key) emits undefined, then emits again once the value arrives.
For keys on the critical rendering path, opt in to eager injection by setting preload: true in the key's UserStorageDefinition. Core then calls getForInjection() during server-side rendering and embeds only the opted-in keys into <kbn-injected-metadata> under userStorage.values, so those values are available synchronously before the first React render without any in-browser fetch.
register(definitions)Register from your plugin's server-side setup lifecycle. The same register() call can include multiple keys with different schemas, scopes, and preload settings.
import { z } from '@kbn/zod/v4';
import type { Plugin, CoreSetup } from '@kbn/core/server';
export class MyPlugin implements Plugin {
public setup(core: CoreSetup) {
core.userStorage.register({
'myPlugin:nav-layout': {
schema: z.object({
hidden: z.array(z.string()),
order: z.array(z.string()),
}),
defaultValue: { hidden: [], order: [] },
scope: 'space',
preload: true, // embed in HTML at first paint; needed on the critical render path
},
'myPlugin:tour-dismissed': {
schema: z.boolean(),
defaultValue: false,
scope: 'global',
// preload omitted — lazy-loaded on first access
},
});
}
public start() {}
}
Registration rejects top-level Zod schemas that accept null (reserved as the removal tombstone in saved objects) or undefined (reserved for "no cached value" on the client, and unreliable over JSON since JSON.stringify({ value: undefined }) drops the value key).
asScoped(request)asScoped(request) returns a Promise-based client bound to the authenticated user behind a KibanaRequest. It returns null when the request has no profile_uid (typically API-key authentication or anonymous pages); always check.
const client = core.userStorage.asScoped(request);
if (!client) {
return response.forbidden({ body: { message: 'User profile not available' } });
}
const layout = await client.get<NavLayout>('myPlugin:nav-layout');
await client.set('myPlugin:nav-layout', { hidden: ['discover'], order: [] });
await client.remove('myPlugin:tour-dismissed'); // resets the key to its default
Server reads always return the resolved value (user override or registered default), never undefined. Writes are validated against the registered schema and reject with a Zod error on mismatch.
The server client surface:
| Method | Returns | Notes |
|---|---|---|
get<T>(key) | Promise<T> | User override or registered default. |
set<T>(key, value) | Promise<T> | Validates, persists, resolves to the validated value. |
remove(key) | Promise<void> | Clears the user override; the key falls back to its default. |
getForInjection() | Promise<Record<string, unknown>> | Resolves all preload: true keys at once. Used by the rendering service; see below. |
getForInjection() is how preloaded values reach the browser synchronously. The core rendering service calls it for you during SSR — you do not call it from a route — but the same asScoped(request) client is the entry point whenever you need a user's value on the server (for example, to seed a server-rendered page or to make a server-side decision based on a preference):
// Inside a request handler, resolve the user's preference server-side.
const client = core.userStorage.asScoped(request);
const layout = client
? await client.get<NavLayout>('myPlugin:nav-layout')
: defaultNavLayout; // no profile_uid — fall back to the default
Because myPlugin:nav-layout was registered with preload: true, the rendering service also embeds its resolved value into the initial HTML, so the browser cache is warm before the first React render — no extra fetch on the client.
Three internal routes back the browser client; consumers do not normally call them directly:
| Method | Path | Body | Returns |
|---|---|---|---|
GET | /internal/user_storage/{key} | — | { value } — the stored value or registered default |
PUT | /internal/user_storage/{key} | { value } | 200 on success, 400 on validation |
DELETE | /internal/user_storage/{key} | — | 200 on success |
All three return 403 when the request has no profile_uid.
The browser surface is in @kbn/core-user-storage-browser and is exposed on core.userStorage at both setup and start. The client is synchronous for reads (cache-backed) and asynchronous for writes (HTTP-backed):
| Method | Returns | Notes |
|---|---|---|
peek<T>(key, default?) | T | undefined | Pure cache read, never fetches. Safe in React render. |
get<T>(key, default?) | T | undefined | Cache read; first miss on a lazy key triggers a background fetch. |
get$<T>(key, default?) | Observable<T | undefined> | Current value, then every future value for the key. |
set<T>(key, value) | Promise<T> | PUT; caches the server-validated value on success. |
remove(key) | Promise<void> | DELETE; clears the cached override. |
getUpdate$() | Observable<UserStorageUpdate> | Successful set/remove events across all keys (not lazy hydrations). |
getHttpError$() | Observable<Error> | Errors from set / remove / lazy-fetch calls. |
import type { IUserStorageClient } from '@kbn/core-user-storage-browser';
const layout = core.userStorage.get<NavLayout>('myPlugin:nav-layout', defaultLayout);
await core.userStorage.set('myPlugin:nav-layout', nextLayout);
await core.userStorage.remove('myPlugin:tour-dismissed');
Pass a defaultValue to get(key, default) so consumers never see undefined. For keys with preload: true the cache is pre-populated at first paint, so get is truly synchronous. For lazy keys, the first get triggers a background fetch and returns undefined (or defaultValue) until the response arrives.
The observables drive live updates and centralized error handling:
core.userStorage.get$<NavLayout>('myPlugin:nav-layout').subscribe((layout) => {
// emits the cached value (or undefined) on subscribe, again when a lazy fetch lands,
// and again on every successful set/remove for this key
});
core.userStorage.getUpdate$().subscribe((update) => {
// fires on every successful set/remove across all keys (not on lazy fetches)
if (update.type === 'set') console.log(update.key, update.newValue);
if (update.type === 'remove') console.log(update.key, 'removed');
});
core.userStorage.getHttpError$().subscribe((err) => {
// fires when a set/remove/lazy-fetch HTTP call fails — wire to a toast / telemetry
});
Wrap the component tree that needs User Storage in a <UserStorageProvider>. The hook throws a clear error if no provider is mounted in the tree.
import {
UserStorageProvider,
useUserStorage,
useUserStorageClient,
} from '@kbn/core-user-storage-browser';
// In your application's mount root:
const App = ({ core }: { core: CoreStart }) => (
<UserStorageProvider userStorage={core.userStorage}>
<NavLayoutEditor />
</UserStorageProvider>
);
// In a component — the nav-customization example:
interface NavLayout {
hidden: string[];
order: string[];
}
const NavLayoutEditor = () => {
const [layout, setLayout] = useUserStorage<NavLayout>('myPlugin:nav-layout', {
hidden: [],
order: [],
});
return (
<button onClick={() => setLayout({ ...layout, hidden: [...layout.hidden, 'discover'] })}>
Hide Discover
</button>
);
};
useUserStorage(key, defaultValue?) returns [value, setter]. The value reflects the synchronous cache and re-renders on change. The setter persists via HTTP, refreshes the cache on success, and resolves to the validated value. If the HTTP write fails, the cache is unchanged, the returned promise rejects, and the error is published to getHttpError$.
Because myPlugin:nav-layout is registered with preload: true, layout is populated synchronously on the very first render — no loading state needed.
For the less common operations — remove, getUpdate$, getHttpError$ — reach for the underlying client:
const client = useUserStorageClient();
const onResetTour = () => client.remove('myPlugin:tour-dismissed');
Use the mocks in @kbn/core-user-storage-browser-mocks (browser) and @kbn/core-user-storage-server-mocks (server) instead of stubbing core.userStorage by hand.
// Browser:
import { userStorageServiceMock } from '@kbn/core-user-storage-browser-mocks';
const userStorage = userStorageServiceMock.createStartContract();
userStorage.get.mockReturnValue({ hidden: ['discover'], order: [] });
render(
<UserStorageProvider userStorage={userStorage}>
<ComponentUnderTest />
</UserStorageProvider>
);
// Server:
import { userStorageServiceMock } from '@kbn/core-user-storage-server-mocks';
const userStorage = userStorageServiceMock.createStartContract();
userStorage.asScoped.mockReturnValue({
get: jest.fn().mockResolvedValue({ hidden: ['discover'], order: [] }),
getForInjection: jest.fn().mockResolvedValue({}),
set: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
});
The browser mock's createStartContract() returns a fully-mocked IUserStorageClient where every method is a jest.fn(). Override individual methods with mockReturnValue / mockResolvedValue per test.
profile_uidIf your feature must work for users without a profile (anonymous pages, API-key auth), do not depend on User Storage. The browser cache will be empty and writes will return 403. Either layer over localStorage for those cases or design the feature so missing User Storage is acceptable (e.g. fall back to defaults).
The browser cache is a snapshot taken at page render. If a second tab writes a different value, the first tab will not see it until the next reload. Cross-tab synchronization is on the roadmap but is not implemented today.
set() does not update the cache until the HTTP write succeeds. If your UI needs immediate visual feedback during the in-flight write, manage that intermediate state at the component level — e.g. with a separate "pending value" piece of useState — and only commit to the User Storage value on success.
There is no built-in migration framework. To evolve a schema:
myPlugin:nav-layout-v2), copy values lazily on read, and deprecate the old key.User Storage values are persisted in unencrypted system Saved Objects. Treat them as user-readable; do not persist tokens, credentials, or anything that should be encrypted at rest.
| Package | Visibility | Contents |
|---|---|---|
@kbn/core-user-storage-common | shared | UserStorageDefinition, UserStorageScope, server IUserStorageClient |
@kbn/core-user-storage-server | shared | UserStorageServiceSetup, UserStorageServiceStart |
@kbn/core-user-storage-browser | shared | Browser IUserStorageClient, UserStorageProvider, useUserStorage, useUserStorageClient |
@kbn/core-user-storage-server-mocks | dev only | userStorageServiceMock (server) |
@kbn/core-user-storage-browser-mocks | dev only | userStorageServiceMock (browser) |
@kbn/core-user-storage-server-internal | private | Server implementation |
@kbn/core-user-storage-browser-internal | private | Browser implementation |