---
title: Persistence
description: How c15t stores consent choices in a JavaScript app, with the
  storage key, cookie domain and lifetime options, multi-tab sync, reconcile and
  clear, and createPersistence for your own kernel.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## Where choices are stored

The persistence module writes each recorded choice to a first-party cookie
and to localStorage, and reads them back when the page loads. The visitor's
choice survives reloads and later visits without a backend request.
`@c15t/browser` and `createConsentRuntime` run it by default.

|Stored item|Name, with the default key|
|--|--|
|Category choice|`c15t` cookie and localStorage key|
|Notice acknowledgement|`c15t-notice`|
|Vendor choice|`c15t-vendors`|
|Saves waiting to reach the backend|a separate localStorage queue|

The cookie is readable by your server, so a server-rendered page can see the
visitor's choice before any script runs.

## When records are read and written

Stored records are read synchronously when the module starts, so a
returning visitor's choice applies before the first banner renders.

The code that writes and reconciles records is a separate chunk. Once a
banner or dialog has been shown, it starts loading in browser idle time after
the page's `load` event. It also starts at the first write or reconciliation
if one comes sooner. A returning visitor who sees no prompt downloads it only
when something needs it. Until it has loaded:

* A save waits for its record to be stored before its backend request
  leaves, and before the save resolves. On the first save of a page load,
  this can add the time it takes to fetch the chunk.
* If the chunk fails to load, the save keeps waiting rather than count as
  stored. Its request does not leave, and a revocation reload does not run
  while storage still holds the old choice. c15t tries the chunk again after
  1, 4 and 16 seconds, and at every later save, focus or tab change.
* `reconcile()` returns `false` and runs when the chunk lands.

`clear()` needs no chunk. It removes every record from the cookie and
localStorage, stores the clear epoch and resets the kernel's records before
it returns, so reloading right after a clear cannot restore a cleared choice.

* `pagehide` cannot store anything. A page restored from the back/forward
  cache stores its pending records when the chunk lands. A page that unloads
  first loses them, along with the save request still waiting for them.

After the chunk has loaded, each write runs in the macrotask after the
visitor acts, and `pagehide` runs any write still waiting.

## Name, domain and lifetime

`storageConfig` sets where and how long records live:

|Option|Default|What it does|
|--|--|--|
|`storageKey`|`'c15t'`|The cookie and localStorage name. Other records use it as a prefix.|
|`crossSubdomain`|`false`|Set the cookie on the root domain, so `www.example.com` and `shop.example.com` share it.|
|`defaultDomain`|current host|An explicit cookie domain, such as `'.example.com'`. Wins over `crossSubdomain`.|
|`defaultExpiryDays`|`365`|Cookie lifetime in days. The policy's own validity still decides when a choice expires.|

Use the same `storageConfig` on every site that shares the cookie. Pass it to
`init()` or `createConsentRuntime`, or inside `persistence` for a runtime:

```ts
storageConfig: { crossSubdomain: true },
```

## Keep open tabs in step

Tabs on the same origin share localStorage, so a choice made in one tab
reaches the others at once through the `storage` event. A tab on another
subdomain that shares only the cookie gets no such event. It reads stored
records again when the page becomes visible or the window regains focus.

Call `runtime.reconcileStorage()` to read them at another moment, for
example after a response set the consent cookie. With `@c15t/browser`, call
`client.runtime.reconcileStorage()`. `persistence: { sync: false }` on the
runtime or the `@c15t/browser` client turns the automatic reads off.
[Keep open tabs in step](/docs/concepts/consent-state#keep-open-tabs-in-step)
explains the merge rules.

## Turn it off

`persistence: false` on `createConsentRuntime` or on `init()` from
`@c15t/browser` keeps choices in memory only.
Every page load starts without a choice, so the banner shows on every page.

## Attach it to your own kernel

A kernel from `createConsentKernel` stores nothing. Create the module before
`commands.init()` so stored choices apply first:

```ts title="src/consent.ts"
import { createConsentKernel, createHostedTransport } from 'c15t';
import { createPersistence } from 'c15t/modules/persistence';
import { createScriptLoader } from 'c15t/modules/script-loader';

import { scripts } from './scripts';

export const startConsentKernel = async function startConsentKernel(
	backendURL: string
) {
	const kernel = createConsentKernel({
		transport: createHostedTransport({ backendURL }),
	});
	// Each module is yours to create and dispose. This kernel has no iframe
	// blocker, network blocker, data clearing or reload after revocation.
	const persistence = createPersistence({ kernel });
	const loader = createScriptLoader({ kernel, scripts });
	await kernel.commands.init();
	return {
		dispose() {
			loader.dispose();
			persistence.dispose();
			kernel.dispose();
		},
		kernel,
	};
};
```

`createPersistence({ kernel, storageConfig?, skipHydration?, sync? })`
returns:

|Member|What it does|
|--|--|
|`hydrate()`|Read storage again. Returns whether any record was found.|
|`reconcile()`|Merge records another tab or runtime stored. Returns whether anything changed.|
|`clear()`|Delete every c15t record and reset the kernel's records.|
|`dispose()`|Stop writing and remove the tab listeners. Stored records stay.|

`skipHydration: true` skips the first read, for a kernel a server already
seeded with the visitor's records.

## Check it works

1. Accept all and open DevTools, Application. A `c15t` cookie and a `c15t`
   localStorage entry exist.
2. Reload. The banner stays closed.
3. Open a second tab, reject all there and switch back. The first tab now
   shows the rejection.
