Skip to content
5 changes: 5 additions & 0 deletions .changeset/fluffy-masks-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: use IndexedDB for snapshots instead of sessionStorage
23 changes: 8 additions & 15 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { decode_pathname, strip_hash, make_trackable, normalize_path } from '../
import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js';
import { parse, parse_server_route } from './parse.js';
import * as storage from './session-storage.js';
import * as snapshots from './snapshot-storage.svelte.js';
import {
find_anchor,
resolve_url,
Expand All @@ -29,7 +30,6 @@ import {
PRELOAD_PRIORITIES,
SCROLL_KEY,
STATES_KEY,
SNAPSHOT_KEY,
PAGE_URL_KEY
} from './constants.js';
import { validate_page_exports } from '../../utils/exports.js';
Expand Down Expand Up @@ -86,12 +86,6 @@ const resetters = [];
*/
const scroll_positions = storage.get(SCROLL_KEY) ?? {};

/**
* navigation index -> any
* @type {Record<string, any[]>}
*/
const snapshots = storage.get(SNAPSHOT_KEY) ?? {};

/**
* @deprecated this is a temporary measure to avoid a regression, replace with nested `RenderNode` classes
*/
Expand Down Expand Up @@ -154,11 +148,7 @@ function clear_onward_history(current_history_index, current_navigation_index) {
i += 1;
}

i = current_navigation_index + 1;
while (snapshots[i]) {
delete snapshots[i];
i += 1;
}
snapshots.truncate(current_navigation_index);
}

/**
Expand Down Expand Up @@ -365,6 +355,7 @@ export async function start(_app, _target, hydrate) {

app = _app;

await snapshots.init();
await _app.hooks.init?.();

routes = __SVELTEKIT_CLIENT_ROUTING__ ? parse(_app) : [];
Expand Down Expand Up @@ -524,13 +515,16 @@ function reset_invalidation() {
/** @param {number} index */
function capture_snapshot(index) {
if (components.some((c) => c?.snapshot)) {
snapshots[index] = components.map((c) => c?.snapshot?.capture());
void snapshots.set(
index,
components.map((c) => c?.snapshot?.capture())
);
}
}

/** @param {number} index */
function restore_snapshot(index) {
snapshots[index]?.forEach((value, i) => {
snapshots.get(index)?.forEach((value, i) => {
components[i]?.snapshot?.restore(value);
});
}
Expand All @@ -540,7 +534,6 @@ function persist_state() {
storage.set(SCROLL_KEY, scroll_positions);

capture_snapshot(current_navigation_index);
storage.set(SNAPSHOT_KEY, snapshots);
}

/**
Expand Down
273 changes: 273 additions & 0 deletions packages/kit/src/runtime/client/snapshot-storage.svelte.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { DEV } from 'esm-env';
import { SNAPSHOT_KEY } from './constants.js';
import * as storage from './session-storage.js';

const STORE = 'snapshots';

/**
* `sessionStorage` key under which the current browsing session's id is kept.
* `sessionStorage` is per-tab and cleared when the tab is closed, but survives
* reloads — exactly the lifetime we want snapshots (which may contain large
* `File`/`Blob` objects) to have.
*/
const SESSION_KEY = SNAPSHOT_KEY + ':session';

/**
* Prefix for the Web Locks we hold, one per browsing session. A held lock named
* `session:<id>` signals that the tab owning `<id>` is still alive; the lock is
* released automatically by the browser when that tab is closed or crashes, so
* the set of currently-held `session:*` locks is an accurate, self-cleaning
* record of which sessions' snapshots are still needed.
*/
const LOCK_PREFIX = 'session:';

/**
* In-memory map of snapshots for the current session, populated once from
* IndexedDB during client initialisation (see `init`) so that restores stay
* synchronous
*
* @type {Record<string, any[]>}
*/
const snapshots = {};

/** @type {Promise<IDBDatabase> | null} */
let db_promise = null;

/** @type {string | undefined} */
let session;

let failed = false;
let warned = false;

/**
* The id for the current browsing session. Persisted in `sessionStorage` so it
* survives reloads (allowing snapshots to be restored) but is scoped to the tab
* and discarded when the tab is closed.
*
* The first time it is resolved we also (fire-and-forget) acquire an exclusive
* Web Lock named `session:<id>` that is held for the lifetime of the tab, so
* other tabs can tell this session is still active when pruning stale data.
*
* @returns {string}
*/
function get_session() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicating a tab copies sessionStorage, so the duplicate starts with the same session id and the two tabs then overwrite each other's [session, index] entries. The per-tab sessionStorage map kept duplicates independent after the copy. Since a request for a held lock just queues, the duplicate could detect this at startup with { ifAvailable: true } and mint a fresh id.

if (session === undefined) {
session = storage.get(SESSION_KEY, (value) => value);

if (!session) {
session =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;

storage.set(SESSION_KEY, session, (value) => value);
}

if (typeof navigator !== 'undefined' && navigator.locks) {
// Hold the lock for the lifetime of the tab; it is released
// automatically when the tab is closed or crashes. The callback
// promise intentionally never resolves — do NOT await this.
navigator.locks
.request(`${LOCK_PREFIX}${session}`, { mode: 'exclusive' }, () => new Promise(() => {}))
.catch(() => {
// best-effort; if the lock can't be acquired we simply won't
// advertise this session as active (pruning stays conservative)
});
}
}

return session;
}

/**
* The set of session ids that are still active, derived from the currently-held
* `session:*` Web Locks. The current session is always included, because
* `navigator.locks.query()` may run before this tab's own lock has been granted
* (the request is async), and we must never prune our own snapshots.
*
* Returns `null` when the Web Locks API is unavailable — in that case we cannot
* prove any other session is stale, so callers must not prune.
*
* @param {string} current
* @returns {Promise<Set<string> | null>}
*/
async function get_active_sessions(current) {
if (typeof navigator === 'undefined' || !navigator.locks || !navigator.locks.query) {
return null;
}

const active = new Set([current]);

try {
const { held = [] } = await navigator.locks.query();
for (const lock of held) {
if (lock.name && lock.name.startsWith(LOCK_PREFIX)) {
active.add(lock.name.slice(LOCK_PREFIX.length));
}
}
} catch {
// if the query fails we still know the current session is active
}

return active;
}

/** @returns {Promise<IDBDatabase>} */
function open() {
return (db_promise ??= new Promise((resolve, reject) => {
/** @type {IDBOpenDBRequest} */
const request = indexedDB.open(SNAPSHOT_KEY, 1);

request.onupgradeneeded = () => {
request.result.createObjectStore(STORE);
};

request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}));
}

/**
Comment thread
vercel[bot] marked this conversation as resolved.
* Load the current session's snapshots into an in-memory map keyed by
* navigation index, and prune snapshots belonging to sessions whose tabs are no
* longer alive (determined via the Web Locks API) so they don't accumulate
* indefinitely.
*/
export async function init() {
try {
const db = await open();
const current = get_session();

// `null` means we can't prove any session is stale (no Web Locks API),
// so we must not delete other sessions' data.
const active = await get_active_sessions(current);

await /** @type {Promise<void>} */ (
new Promise((resolve, reject) => {
const transaction = db.transaction(STORE, 'readwrite');
const store = transaction.objectStore(STORE);
const request = store.openCursor();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start() awaits init() before hooks.init and hydration, and this cursor materializes every session's stored values, so a new tab deserializes other tabs' snapshots (potentially multi-megabyte Files) just to prune their keys. getAll(IDBKeyRange.bound([current], [current, []])) would load only this session's values, and the prune could iterate openKeyCursor() instead, or run after hydration entirely.

request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
const key = /** @type {[string, number]} */ (cursor.key);
if (Array.isArray(key) && key[0] === current) {
// only load the current session's snapshots into memory
snapshots[key[1]] = cursor.value;
} else if (active && !(Array.isArray(key) && active.has(key[0]))) {
// stale (closed session) or legacy (non-namespaced) entry —
// only delete when Web Locks let us prove it isn't active
cursor.delete();
}
cursor.continue();
}
};
request.onerror = () => reject(request.error);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
})
);
} catch {
failed = true;

// fall back to sessionStorage
Object.assign(snapshots, storage.get(SNAPSHOT_KEY));
}
}

/**
* Persist a snapshot value for a given navigation index in memory,
* and (in the background, fire-and-forget style) to IndexedDB
*
* @param {number} index
* @param {any} value
*/
export function set(index, value) {
snapshots[index] = $state.snapshot(value);

void put(index, snapshots[index]);
storage.set(SNAPSHOT_KEY, snapshots);

@teemingc teemingc Jul 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this only be done when put fails instead?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can throw synchronously for values IndexedDB accepts. session-storage.js runs stringify(value) outside its try, and JSON.stringify throws on BigInt and circular references, both fine under structured clone. capture_snapshot runs unguarded inside navigate() (client.js:1836), so a snapshot containing such a value fails the navigation instead of just losing the fallback copy. Same applies to truncate below. Alternatively the stringify call could move inside the try in session-storage.js, whose only other caller is SCROLL_KEY.

Suggested change
storage.set(SNAPSHOT_KEY, snapshots);
try {
storage.set(SNAPSHOT_KEY, snapshots);
} catch {
// best-effort, JSON.stringify rejects some structured-clonable values (BigInt, circular refs)
}

}

/**
* @param {number} index
*/
export function get(index) {
if (DEV && failed && !warned) {
warned = true;
console.warn('Failed to restore snapshots from IndexedDB');
}

return snapshots[index];
}

/**
* @param {number} index
*/
export function truncate(index) {
let i = index;

while (snapshots[++i]) {
delete snapshots[i];
void del(i);
}

storage.set(SNAPSHOT_KEY, snapshots);
}

/**
* Persist a snapshot value to IndexedDB
*
* @param {number} index
* @param {any} value
* @returns {Promise<void>}
*/
export async function put(index, value) {
try {
const db = await open();
const current = get_session();
await /** @type {Promise<void>} */ (
new Promise((resolve, reject) => {
const transaction = db.transaction(STORE, 'readwrite');
transaction.objectStore(STORE).put(value, [current, index]);
// Flush promptly so that writes initiated from `visibilitychange`
// (the last reliable signal before unload) have the best chance to
// land before the document is destroyed. Auto-commit would otherwise
// wait for the current task to unwind.
if (typeof transaction.commit === 'function') transaction.commit();
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
})
);
} catch (e) {
if (DEV && /** @type {Error} */ (e).name === 'DataCloneError') {
console.warn('Could not serialize snapshot value. It will not survive a page reload');
}
}
}

/**
* Delete the snapshot value for a given navigation index.
*
* @param {number} index
* @returns {Promise<void>}
*/
async function del(index) {
try {
const db = await open();
const current = get_session();
await /** @type {Promise<void>} */ (
new Promise((resolve, reject) => {
const transaction = db.transaction(STORE, 'readwrite');
transaction.objectStore(STORE).delete([current, index]);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
})
);
} catch {
// best-effort
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script>
let file = $state(/** @type {File | null} */ (null));

/** @type {import('./$types').Snapshot<File | null>} */
export const snapshot = {
capture: () => {
return file;
},
restore: (value) => {
file = value;
}
};
</script>

<input
type="file"
data-testid="picker"
oninput={(e) => (file = e.currentTarget.files?.[0] ?? null)}
/>

{#if file}
<p data-testid="info">{file.name}|{file.size}</p>
{:else}
<p data-testid="info">empty</p>
{/if}
Loading
Loading