Your Browser Has Storage Buckets Now, and They Are Not What You Think

Every web developer has felt the sting: a user running low on disk space, the browser decides to nuke your IndexedDB, and suddenly your offline-first app has amnesia. The old navigator.storage.persist() call was the only lifeline, and it was all-or-nothing. Either everything your origin stored was marked persistent, or nothing was.
The Storage Buckets API changes that completely. It lets you carve your origin's storage into named buckets, each with its own eviction priority, durability guarantees, quota limits, and even expiration dates. Think of it as localStorage growing up and getting a filing cabinet.
But before you assume this is just cloud storage buckets ported to the browser: it is not. The name is where the similarity ends.
The Problem: All Your Data Lives in One Eviction Blast Radius
Before Storage Buckets, every storage API on an origin (IndexedDB, CacheStorage, the Origin Private File System) shared a single eviction fate. When the browser ran into storage pressure, it would look at your origin and make a binary call: keep everything, or torch everything.
The navigator.storage.persist() API tried to help, but it applied to the entire origin. You could not tell the browser “keep the user's unsaved document, but feel free to drop the thumbnail cache.” The granularity simply did not exist.
This is a real problem for applications that store heterogeneous data. An email client cares deeply about unsent drafts but can re-fetch inbox messages from the server. A music streaming app needs the current playlist cached, but last month's recommendations can go. A document editor must preserve offline edits, but the undo history is expendable.
The Storage Buckets API solves this by letting you group related data into named buckets with individual storage policies.
How Storage Buckets Actually Work
The API lives at navigator.storageBuckets and requires a secure context (HTTPS or localhost). It works in both Window and Worker contexts.
The full interface surface of the API looks like this:
// StorageBucketManager - accessed via navigator.storageBuckets
interface StorageBucketManager {
open(name: string, options?: StorageBucketOptions): Promise<StorageBucket>;
keys(): Promise<string[]>;
delete(name: string): Promise<void>;
}
interface StorageBucketOptions {
persisted?: boolean; // default: false
quota?: number; // in bytes; browser may impose a lower limit
expires?: number; // DOMHighResTimeStamp (ms since Unix epoch)
durability?: 'relaxed' | 'strict'; // default: 'relaxed'
}
interface StorageBucket {
readonly name: string;
// Persistence control
persist(): Promise<boolean>;
persisted(): Promise<boolean>;
// Quota introspection
estimate(): Promise<StorageEstimate>;
// Expiry control
setExpires(expires: number): Promise<void>;
expires(): Promise<number | null>;
// Storage endpoints
readonly indexedDB: IDBFactory;
readonly caches: CacheStorage;
getDirectory(): Promise<FileSystemDirectoryHandle>;
readonly locks: LockManager;
}Creating Buckets
// A cache bucket: low priority, browser can evict freely
const cacheBucket = await navigator.storageBuckets.open("thumbnail-cache", {
persisted: false,
durability: "relaxed",
});
// A critical bucket: persist through storage pressure, survive power loss
const draftsBucket = await navigator.storageBuckets.open("unsaved-drafts", {
persisted: true,
durability: "strict",
});The open() call is idempotent. Calling it with the same name returns the existing bucket. The options you pass are hints, meaning the browser may not honor them, so you should always verify:
if (await draftsBucket.persisted() !== true) {
console.warn("Browser declined persistence for drafts bucket");
}Bucket Name Rules
One small detail that can trip you up: bucket names are restricted to lowercase ASCII letters (a-z), digits (0-9), underscores, and hyphens. Names cannot start with _ or -, and the maximum length is 64 characters. Any violation throws a TypeError. This matters if you are generating bucket names programmatically at runtime.
Durability Modes
The durability option is a hint to the browser about how it should handle writes during unexpected power loss:
"strict"tries to flush writes to disk before acknowledging completion. This minimizes data loss on power failure but can be slower and harder on battery life."relaxed"may lose writes from the last few seconds if power is suddenly cut. Writes are faster, battery impact is lower, and the storage device experiences less wear.
Storage Policies at a Glance
| Policy | Values | What It Controls |
|---|---|---|
persisted | true / false | Whether the bucket survives storage pressure eviction |
durability | "strict" / "relaxed" | Trade-off between write performance and power-failure safety |
quota | Number (bytes) | Upper bound on storage for this bucket |
expires | Timestamp (ms) | Auto-expiration date for the bucket's data |
Using Storage APIs Within a Bucket
Every bucket exposes the same familiar storage APIs, just scoped to that bucket:
// IndexedDB scoped to a specific bucket
const db = await new Promise((resolve, reject) => {
const request = draftsBucket.indexedDB.open("documents", 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
db.createObjectStore("notes", { keyPath: "id" });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
// CacheStorage scoped to a specific bucket
const cache = await cacheBucket.caches.open("images");
// Origin Private File System scoped to a specific bucket
const root = await draftsBucket.getDirectory();This is the key insight: you are not learning a new storage API. You are using the same IndexedDB, CacheStorage, and OPFS you already know, just partitioned into independently managed buckets.
Deleting and Enumerating Buckets
// Clean up a specific user's data on logout
await navigator.storageBuckets.delete("user-session-42");
// List all buckets for debugging
const allBuckets = await navigator.storageBuckets.keys();
console.log(allBuckets); // ["thumbnail-cache", "unsaved-drafts"]When you delete a bucket, all IndexedDB databases and caches inside it are force-closed and wiped immediately.
Setting Quotas and Expiration
// Cap recommendation data at 20 MB
const recBucket = await navigator.storageBuckets.open("recommendations", {
quota: 20 * 1024 * 1024,
});
// Check how much space this bucket is using
const estimate = await recBucket.estimate();
console.log(`Using ${estimate.usage} of ${estimate.quota} bytes`);
// Create a bucket that auto-expires in 14 days
const twoWeeks = 14 * 24 * 60 * 60 * 1000;
const newsBucket = await navigator.storageBuckets.open("breaking-news", {
expires: Date.now() + twoWeeks,
});
// Extend expiration if the user is still active
await newsBucket.setExpires(Date.now() + twoWeeks);This Is Not Cloud Storage Buckets
The name "bucket" invites comparisons to S3, Google Cloud Storage, or Azure Blob Storage. Resist that urge. Here is how they differ fundamentally:
| Aspect | Browser Storage Buckets | Cloud Storage Buckets (S3, GCS) |
|---|---|---|
| Location | User's device | Remote server |
| Scope | Single origin, single device | Globally accessible |
| Data model | Namespace over browser APIs | Object/blob storage (HTTP) |
| Persistence | Best-effort hints only | Guaranteed (11 nines) |
| Eviction | Browser may evict under pressure | Persists until deleted |
| Access control | Same-origin policy only | IAM, Signed URLs, ACLs |
| Capacity | Browser quota (hundreds of MB) | Effectively unlimited |
| Networking | Entirely local (offline-capable) | Requires network |
| Cost | Free (device storage) | Pay per GB and request |
| Survives Data Clear | No | Yes |
Browser Storage Buckets are about organizing client-side data with eviction priorities. Cloud storage buckets are about storing and serving objects at scale across the internet. They solve completely different problems.
The one conceptual overlap is lifecycle management. Both let you set expiration policies on data. But in cloud storage, expiration is a cost optimization. In browser storage, expiration is about hygiene and preventing stale data from consuming the user's disk.
Real-World Example: An Offline-First Note-Taking App
Enough theory. Let's build Bucket Notes, a functional offline-first note-taking app that uses Storage Buckets to manage persistence and durability.

Project Structure
We'll organize the project to separate storage logic from UI orchestration:
Storage-Bucket-API-Example/
├── index.html ← entry point (served by any HTTP server)
└── src/
├── styles.css ← all styling
├── app.js ← bootstrap, event wiring
├── ui.js ← DOM rendering helpers
└── storage/
├── bucket-layer.js ← Storage Bucket abstraction
└── note-store.js ← IndexedDB CRUD scoped to bucket
1. The Entry Point (index.html)
We set up a clean structure with an "unsupported" banner that only shows if the Storage Buckets API is missing.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Offline-first note-taking app built with Chrome's Storage Buckets API" />
<title>Bucket Notes - Offline-First Notes</title>
<!-- Google Font: Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="src/styles.css" />
</head>
<body>
<!-- ============================================================ -->
<!-- Unsupported Browser Banner -->
<!-- ============================================================ -->
<div id="unsupported-banner" class="unsupported-banner" hidden>
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<h1>Browser Not Supported</h1>
<p>
This app uses the <strong>Storage Buckets API</strong>,
which requires <strong>Chrome 122+</strong> (or a Chromium-based browser
with the feature enabled).
</p>
<p>
Check availability on
<a href="https://developer.chrome.com/docs/web-platform/storage-buckets" target="_blank" rel="noopener" style="color: #6c63ff;">
Chrome for Developers
</a>.
</p>
</div>
<!-- ============================================================ -->
<!-- Main App Shell -->
<!-- ============================================================ -->
<div id="app" class="app" hidden>
<!-- Sidebar -->
<aside class="sidebar">
<header class="sidebar__header">
<h1 class="sidebar__title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
Bucket Notes
</h1>
<div class="sidebar__actions">
<button id="btn-new" class="btn btn--primary" title="New note (Alt+N)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New
</button>
</div>
</header>
<div id="note-list" class="note-list">
<!-- Rendered by JS -->
</div>
</aside>
<!-- Editor -->
<main class="editor">
<!-- Placeholder (no note selected) -->
<div id="editor-placeholder" class="editor__placeholder">
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<p>Select a note or create a new one</p>
<p><kbd>Alt + N</kbd> to create · <kbd>Ctrl + S</kbd> to save</p>
</div>
<!-- Editor Content (hidden until a note is selected) -->
<div id="editor-content" class="editor__content" hidden>
<header class="editor__header">
<input
id="editor-title"
class="editor__title-input"
type="text"
placeholder="Note title…"
disabled
autocomplete="off"
spellcheck="true"
/>
<button id="btn-save" class="btn" title="Save note (Ctrl+S)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Save
</button>
</header>
<div class="editor__body">
<textarea
id="editor-body"
class="editor__textarea"
placeholder="Start writing…"
disabled
spellcheck="true"
></textarea>
</div>
</div>
</main>
</div>
<!-- Status Bar -->
<footer id="status-bar" class="status-bar">
<span class="status-item">Initializing…</span>
</footer>
<!-- App Bootstrap -->
<script type="module" src="src/app.js"></script>
</body>
</html>2. Styling (src/styles.css)
A dark theme layout to match modern editor aesthetics.
/* ================================================================== */
/* Design Tokens */
/* ================================================================== */
:root {
/* Palette - cool dark theme with accent */
--clr-bg: #0e0f14;
--clr-surface: #15161d;
--clr-surface-alt: #1c1d27;
--clr-border: #2a2b38;
--clr-border-focus: #5b5eff;
--clr-text: #e2e4ed;
--clr-text-muted: #7e8295;
--clr-accent: #6c63ff;
--clr-accent-glow: rgba(108, 99, 255, .25);
--clr-danger: #ff5c6c;
--clr-danger-glow: rgba(255, 92, 108, .18);
--clr-success: #3ddc84;
--clr-success-glow: rgba(61, 220, 132, .18);
/* Typography */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
/* Transitions */
--ease-out: cubic-bezier(.22, 1, .36, 1);
--dur-fast: 150ms;
--dur-med: 250ms;
/* Sidebar */
--sidebar-w: 320px;
}
/* ================================================================== */
/* Reset & Base */
/* ================================================================== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
[hidden] {
display: none !important;
}
html, body {
height: 100%;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.6;
color: var(--clr-text);
background: var(--clr-bg);
-webkit-font-smoothing: antialiased;
}
/* ================================================================== */
/* Unsupported Banner */
/* ================================================================== */
.unsupported-banner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
text-align: center;
padding: var(--space-xl);
gap: var(--space-md);
}
.unsupported-banner svg {
color: var(--clr-danger);
opacity: .8;
}
.unsupported-banner h1 {
font-size: 1.6rem;
font-weight: 700;
}
.unsupported-banner p {
color: var(--clr-text-muted);
max-width: 460px;
}
.unsupported-banner code {
font-family: var(--font-mono);
background: var(--clr-surface-alt);
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: .85em;
}
/* ================================================================== */
/* Layout */
/* ================================================================== */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
/* ================================================================== */
/* Sidebar */
/* ================================================================== */
.sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
display: flex;
flex-direction: column;
background: var(--clr-surface);
border-right: 1px solid var(--clr-border);
}
.sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--clr-border);
}
.sidebar__title {
font-size: 1.1rem;
font-weight: 700;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.sidebar__title svg {
color: var(--clr-accent);
}
.sidebar__actions {
display: flex;
gap: var(--space-sm);
}
/* ================================================================== */
/* Buttons */
/* ================================================================== */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
font-family: var(--font-sans);
font-size: .85rem;
font-weight: 600;
color: var(--clr-text);
background: var(--clr-surface-alt);
border: 1px solid var(--clr-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--dur-fast) var(--ease-out);
white-space: nowrap;
}
.btn:hover {
background: var(--clr-border);
border-color: var(--clr-border-focus);
box-shadow: 0 0 0 3px var(--clr-accent-glow);
}
.btn:active {
transform: scale(.96);
}
.btn--primary {
background: var(--clr-accent);
border-color: var(--clr-accent);
color: #fff;
}
.btn--primary:hover {
background: #7b73ff;
border-color: #7b73ff;
}
.btn--danger {
color: var(--clr-danger);
}
.btn--danger:hover {
background: var(--clr-danger-glow);
border-color: var(--clr-danger);
box-shadow: 0 0 0 3px var(--clr-danger-glow);
}
.btn--icon {
padding: var(--space-sm);
}
/* ================================================================== */
/* Note List */
/* ================================================================== */
.note-list {
flex: 1;
overflow-y: auto;
padding: var(--space-sm);
scrollbar-width: thin;
scrollbar-color: var(--clr-border) transparent;
}
.note-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--clr-text-muted);
text-align: center;
gap: var(--space-sm);
}
.note-list-empty svg {
opacity: .4;
}
.note-list-empty-hint {
font-size: .82rem;
opacity: .6;
}
/* ------------------------------------------------------------------ */
/* Note Card */
/* ------------------------------------------------------------------ */
.note-card {
display: flex;
align-items: flex-start;
width: 100%;
text-align: left;
background: transparent;
color: var(--clr-text);
font-family: var(--font-sans);
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-xs);
cursor: pointer;
transition: all var(--dur-fast) var(--ease-out);
}
.note-card:hover {
background: var(--clr-surface-alt);
border-color: var(--clr-border);
}
.note-card--active {
background: var(--clr-surface-alt);
border-color: var(--clr-accent);
box-shadow: 0 0 0 2px var(--clr-accent-glow);
}
.note-card__content {
flex: 1;
min-width: 0;
}
.note-card__title {
font-size: .92rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.note-card__preview {
font-size: .8rem;
color: var(--clr-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.note-card__time {
font-size: .72rem;
color: var(--clr-text-muted);
opacity: .7;
margin-top: var(--space-xs);
display: block;
}
.note-card__delete {
flex-shrink: 0;
background: none;
border: none;
color: var(--clr-text-muted);
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-sm);
transition: all var(--dur-fast) var(--ease-out);
opacity: 0;
margin-left: var(--space-sm);
}
.note-card:hover .note-card__delete {
opacity: 1;
}
.note-card__delete:hover {
color: var(--clr-danger);
background: var(--clr-danger-glow);
}
/* ================================================================== */
/* Editor Panel */
/* ================================================================== */
.editor {
flex: 1;
display: flex;
flex-direction: column;
background: var(--clr-bg);
overflow: hidden;
}
.editor__content {
flex: 1;
display: flex;
flex-direction: column;
}
.editor__header {
display: flex;
align-items: center;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--clr-border);
gap: var(--space-sm);
}
.editor__title-input {
flex: 1;
font-family: var(--font-sans);
font-size: 1.15rem;
font-weight: 700;
color: var(--clr-text);
background: transparent;
border: none;
outline: none;
padding: var(--space-xs) 0;
}
.editor__title-input::placeholder {
color: var(--clr-text-muted);
opacity: .6;
}
.editor__title-input:disabled {
opacity: .3;
}
.editor__body {
flex: 1;
padding: var(--space-lg);
display: flex; /* Added to allow textarea to fill height */
overflow: hidden;
}
.editor__textarea {
width: 100%;
flex: 1; /* Changed from height: 100% to flex: 1 for better behavior in flex container */
font-family: var(--font-sans);
font-size: .95rem;
line-height: 1.75;
color: var(--clr-text);
background: transparent;
border: none;
outline: none;
resize: none;
scrollbar-width: thin;
scrollbar-color: var(--clr-border) transparent;
}
.editor__textarea::placeholder {
color: var(--clr-text-muted);
opacity: .5;
}
.editor__textarea:disabled {
opacity: .3;
}
/* placeholder state */
.editor__placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--clr-text-muted);
gap: var(--space-md);
text-align: center;
}
.editor__placeholder svg {
opacity: .25;
}
.editor__placeholder p {
font-size: .9rem;
opacity: .6;
}
.editor__placeholder kbd {
font-family: var(--font-mono);
font-size: .78rem;
background: var(--clr-surface-alt);
border: 1px solid var(--clr-border);
border-radius: var(--radius-sm);
padding: 2px 8px;
}
/* ================================================================== */
/* Status Bar */
/* ================================================================== */
.status-bar {
display: flex;
align-items: center;
gap: var(--space-lg);
padding: var(--space-sm) var(--space-lg);
font-size: .75rem;
color: var(--clr-text-muted);
border-top: 1px solid var(--clr-border);
background: var(--clr-surface);
}
.status-item {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.status-dot--persisted {
background: var(--clr-success);
box-shadow: 0 0 6px var(--clr-success-glow);
}
.status-dot--volatile {
background: var(--clr-danger);
box-shadow: 0 0 6px var(--clr-danger-glow);
}
/* ================================================================== */
/* Toast */
/* ================================================================== */
.toast {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: var(--space-sm) var(--space-lg);
font-size: .85rem;
font-weight: 500;
border-radius: var(--radius-md);
pointer-events: none;
opacity: 0;
transition: all var(--dur-med) var(--ease-out);
z-index: 1000;
}
.toast--visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.toast--info {
background: var(--clr-surface-alt);
border: 1px solid var(--clr-border);
color: var(--clr-text);
}
.toast--success {
background: #132a1e;
border: 1px solid var(--clr-success);
color: var(--clr-success);
}
.toast--error {
background: #2a1318;
border: 1px solid var(--clr-danger);
color: var(--clr-danger);
}
/* ================================================================== */
/* Responsive */
/* ================================================================== */
@media (max-width: 720px) {
:root {
--sidebar-w: 100%;
}
.app {
flex-direction: column;
}
.sidebar {
width: 100%;
min-width: unset;
max-height: 45vh;
border-right: none;
border-bottom: 1px solid var(--clr-border);
}
}
/* ================================================================== */
/* Animations */
/* ================================================================== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.note-card {
animation: fadeIn var(--dur-med) var(--ease-out) both;
}
3. The Storage Bucket Layer (src/storage/bucket-layer.js)
This file contains the core logic for initializing the bucket with strict durability and persistence.
/**
* bucket-layer.js
* Thin abstraction over the Chrome Storage Buckets API.
* Provides feature detection, bucket lifecycle, and quota helpers.
*
* @see https://developer.chrome.com/docs/web-platform/storage-buckets
* @see https://wicg.github.io/storage-buckets/
*/
/**
* Returns the storage bucket manager (either modern or legacy).
*/
function getManager() {
if (typeof navigator === 'undefined') return null;
const modern = navigator.storage?.buckets;
const legacy = navigator.storageBuckets;
console.log('Bucket Layer - getManager:', { modern: !!modern, legacy: !!legacy });
return modern ?? legacy ?? null;
}
/**
* Returns true when the Storage Buckets API is available.
*/
export function isSupported() {
const supported = !!getManager();
console.log('Bucket Layer - isSupported:', supported);
return supported;
}
/**
* Asserts that the API is available or throws a descriptive error.
*/
function assertSupport() {
if (!isSupported()) {
throw new Error(
'Storage Buckets API is not supported in this browser. ' +
'Please use a modern Chromium browser with the feature enabled.'
);
}
}
/**
* Opens (or creates) a named storage bucket.
*
* @param {string} name - bucket name (lowercase, alphanumeric + hyphens)
* @param {object} [options]
* @param {boolean} [options.persisted=true] - survive storage pressure
* @param {'strict'|'relaxed'} [options.durability='strict'] - write durability hint
* @returns {Promise<StorageBucket>}
*/
export async function initBucket(name, options = {}) {
const manager = getManager();
if (!manager) assertSupport();
const { persisted = true, durability = 'strict' } = options;
const bucket = await manager.open(name, {
persisted,
durability,
});
return bucket;
}
/**
* Deletes a named storage bucket and all its associated data.
*
* @param {string} name
* @returns {Promise<void>}
*/
export async function deleteBucket(name) {
const manager = getManager();
if (!manager) assertSupport();
await manager.delete(name);
}
/**
* Lists all bucket names for the current origin.
*
* @returns {Promise<string[]>}
*/
export async function listBuckets() {
const manager = getManager();
if (!manager) assertSupport();
return manager.keys();
}
/**
* Returns quota usage information for a bucket.
*
* @param {StorageBucket} bucket
* @returns {Promise<{ usage: number, quota: number }>}
*/
export async function getBucketEstimate(bucket) {
const estimate = await bucket.estimate();
return {
usage: estimate.usage ?? 0,
quota: estimate.quota ?? 0,
};
}
/**
* Checks whether the bucket is actually persisted.
*
* @param {StorageBucket} bucket
* @returns {Promise<boolean>}
*/
export async function isBucketPersisted(bucket) {
return bucket.persisted();
}4. The Data Store (src/storage/note-store.js)
Here we open IndexedDB through the bucket. This is the secret sauce: the database is now namespace-isolated and governed by the bucket's eviction policy.
/**
* note-store.js
* IndexedDB-backed CRUD for notes, scoped to a Storage Bucket.
*
* The database lives inside the bucket so the browser can manage its
* persistence and eviction independently of other site data.
*/
const DB_NAME = 'notes-db';
const DB_VERSION = 1;
const STORE_NAME = 'notes';
/**
* Opens (or upgrades) the IndexedDB database scoped to the given bucket.
*
* @param {StorageBucket} bucket
* @returns {Promise<IDBDatabase>}
*/
function openDatabase(bucket) {
return new Promise((resolve, reject) => {
// Use the bucket-scoped IndexedDB factory instead of window.indexedDB
const request = bucket.indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
store.createIndex('updatedAt', 'updatedAt', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Creates a NoteStore bound to the provided Storage Bucket.
*
* Usage:
* const store = await createNoteStore(bucket);
* await store.save({ title: 'Hello', body: '...' });
* const notes = await store.getAll();
*/
export async function createNoteStore(bucket) {
const db = await openDatabase(bucket);
/**
* Wraps a read/write transaction in a promise.
*/
function withTransaction(mode, callback) {
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
let result;
try {
result = callback(store, tx);
} catch (err) {
reject(err);
return;
}
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
/**
* Resolves an IDBRequest inside a transaction.
*/
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
return {
/**
* Returns all notes, sorted by updatedAt descending (newest first).
* @returns {Promise<Array<object>>}
*/
async getAll() {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const index = store.index('updatedAt');
const notes = [];
return new Promise((resolve, reject) => {
// Open a cursor in 'prev' direction for descending order
const cursorReq = index.openCursor(null, 'prev');
cursorReq.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
notes.push(cursor.value);
cursor.continue();
} else {
resolve(notes);
}
};
cursorReq.onerror = () => reject(cursorReq.error);
});
},
/**
* Gets a single note by ID.
* @param {string} id
* @returns {Promise<object|undefined>}
*/
async getById(id) {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.get(id);
return requestToPromise(request);
},
/**
* Inserts or updates a note.
* Automatically sets `id`, `createdAt`, and `updatedAt`.
*
* @param {object} note - must include at least `title` and `body`
* @returns {Promise<object>} the persisted note
*/
async save(note) {
const now = Date.now();
const record = {
...note,
id: note.id || crypto.randomUUID(),
createdAt: note.createdAt || now,
updatedAt: now,
};
await withTransaction('readwrite', (store) => {
store.put(record);
});
return record;
},
/**
* Deletes a note by its ID.
* @param {string} id
* @returns {Promise<void>}
*/
async remove(id) {
await withTransaction('readwrite', (store) => {
store.delete(id);
});
},
/**
* Removes all notes from the store.
* @returns {Promise<void>}
*/
async clearAll() {
await withTransaction('readwrite', (store) => {
store.clear();
});
},
/**
* Returns the total count of notes.
* @returns {Promise<number>}
*/
async count() {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const request = store.count();
return requestToPromise(request);
},
};
}5. UI Helpers (src/ui.js)
Pure DOM helpers for rendering the note-taking UI. No framework dependencies-just plain DOM manipulation.
/**
* ui.js
* Pure DOM helpers for rendering the note-taking UI.
* No framework dependencies - just plain DOM manipulation.
*/
/**
* Formats a timestamp (ms since epoch) into a human-readable relative string.
* @param {number} ts
* @returns {string}
*/
function timeAgo(ts) {
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* Escapes HTML entities to prevent XSS in rendered text.
*/
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/* ------------------------------------------------------------------ */
/* Note List (Sidebar) */
/* ------------------------------------------------------------------ */
/**
* Renders the note list into the sidebar container.
*
* @param {HTMLElement} container - the sidebar list element
* @param {Array} notes - array of note objects
* @param {string|null} activeId - currently selected note id
* @param {Function} onSelect - callback(noteId)
* @param {Function} onDelete - callback(noteId)
*/
export function renderNoteList(container, notes, activeId, onSelect, onDelete) {
container.innerHTML = '';
if (notes.length === 0) {
const empty = document.createElement('div');
empty.className = 'note-list-empty';
empty.innerHTML = `
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<p>No notes yet</p>
<p class="note-list-empty-hint">Create one to get started</p>
`;
container.appendChild(empty);
return;
}
for (const note of notes) {
const card = document.createElement('button');
card.className = `note-card${note.id === activeId ? ' note-card--active' : ''}`;
card.dataset.id = note.id;
card.type = 'button';
const preview = (note.body || '').slice(0, 80).replace(/\n/g, ' ');
card.innerHTML = `
<div class="note-card__content">
<h3 class="note-card__title">${escapeHtml(note.title || 'Untitled')}</h3>
<p class="note-card__preview">${escapeHtml(preview) || 'Empty note'}</p>
<time class="note-card__time">${timeAgo(note.updatedAt)}</time>
</div>
<button class="note-card__delete" title="Delete note" type="button" aria-label="Delete note">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/>
<path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
</button>
`;
// Select handler
card.addEventListener('click', (e) => {
// Don't select if delete button was clicked
if (e.target.closest('.note-card__delete')) return;
onSelect(note.id);
});
// Delete handler
const delBtn = card.querySelector('.note-card__delete');
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
onDelete(note.id);
});
container.appendChild(card);
}
}
/* ------------------------------------------------------------------ */
/* Editor */
/* ------------------------------------------------------------------ */
/**
* Populates the editor fields with a note's data.
*
* @param {object} note
* @param {HTMLInputElement} titleInput
* @param {HTMLTextAreaElement} bodyInput
*/
export function populateEditor(note, titleInput, bodyInput) {
titleInput.value = note?.title ?? '';
bodyInput.value = note?.body ?? '';
titleInput.disabled = !note;
bodyInput.disabled = !note;
}
/**
* Clears the editor and disables inputs.
*/
export function clearEditor(titleInput, bodyInput) {
populateEditor(null, titleInput, bodyInput);
}
/* ------------------------------------------------------------------ */
/* Status Bar */
/* ------------------------------------------------------------------ */
/**
* Updates the quota / status bar.
*
* @param {HTMLElement} el
* @param {{ usage: number, quota: number }} estimate
* @param {boolean} persisted
* @param {number} noteCount
*/
export function updateStatusBar(el, estimate, persisted, noteCount) {
const usageMB = (estimate.usage / (1024 * 1024)).toFixed(2);
const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(0);
const pct = estimate.quota > 0
? ((estimate.usage / estimate.quota) * 100).toFixed(1)
: '0.0';
el.innerHTML = `
<span class="status-item">
<span class="status-dot ${persisted ? 'status-dot--persisted' : 'status-dot--volatile'}"></span>
${persisted ? 'Persisted' : 'Best-effort'}
</span>
<span class="status-item">${noteCount} note${noteCount !== 1 ? 's' : ''}</span>
<span class="status-item">${usageMB} / ${quotaMB} MB (${pct}%)</span>
`;
}
/* ------------------------------------------------------------------ */
/* Toast */
/* ------------------------------------------------------------------ */
let toastTimeout = null;
/**
* Shows a brief toast notification.
*
* @param {string} message
* @param {'info'|'success'|'error'} [type='info']
*/
export function showToast(message, type = 'info') {
let toast = document.getElementById('toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.className = `toast toast--${type} toast--visible`;
clearTimeout(toastTimeout);
toastTimeout = setTimeout(() => {
toast.classList.remove('toast--visible');
}, 2500);
}
/* ------------------------------------------------------------------ */
/* Feature-detection banner */
/* ------------------------------------------------------------------ */
/**
* Shows or hides the unsupported-browser banner.
*/
export function showUnsupportedBanner(show) {
const banner = document.getElementById('unsupported-banner');
if (banner) banner.hidden = !show;
const app = document.getElementById('app');
if (app) app.hidden = show;
}6. Application Logic (src/app.js)
Wiring everything together: initializing the bucket, loading notes, and handling user input.
/**
* app.js
* Application entry point. Bootstraps the storage bucket,
* wires up events, and manages app state.
*/
import { isSupported, initBucket, getBucketEstimate, isBucketPersisted } from './storage/bucket-layer.js';
import { createNoteStore } from './storage/note-store.js';
import {
renderNoteList,
populateEditor,
clearEditor,
updateStatusBar,
showToast,
showUnsupportedBanner,
} from './ui.js';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const BUCKET_NAME = 'notes';
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
let noteStore = null;
let bucket = null;
let activeNoteId = null;
let notes = [];
let saveTimeout = null;
/* ------------------------------------------------------------------ */
/* DOM references */
/* ------------------------------------------------------------------ */
const $noteList = document.getElementById('note-list');
const $titleInput = document.getElementById('editor-title');
const $bodyInput = document.getElementById('editor-body');
const $statusBar = document.getElementById('status-bar');
const $btnNew = document.getElementById('btn-new');
const $btnSave = document.getElementById('btn-save');
const $editorPlaceholder = document.getElementById('editor-placeholder');
const $editorContent = document.getElementById('editor-content');
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function setEditorVisible(visible) {
$editorPlaceholder.hidden = visible;
$editorContent.hidden = !visible;
}
async function refreshStatus() {
if (!bucket) return;
try {
const estimate = await getBucketEstimate(bucket);
const persisted = await isBucketPersisted(bucket);
const count = notes.length;
updateStatusBar($statusBar, estimate, persisted, count);
} catch {
// silently swallow - quota reporting may not be available
}
}
async function loadNotes() {
notes = await noteStore.getAll();
}
function renderList() {
renderNoteList($noteList, notes, activeNoteId, handleSelectNote, handleDeleteNote);
}
/* ------------------------------------------------------------------ */
/* Event Handlers */
/* ------------------------------------------------------------------ */
function handleSelectNote(id) {
activeNoteId = id;
const note = notes.find(n => n.id === id);
if (note) {
populateEditor(note, $titleInput, $bodyInput);
setEditorVisible(true);
}
renderList();
}
async function handleDeleteNote(id) {
await noteStore.remove(id);
if (activeNoteId === id) {
activeNoteId = null;
clearEditor($titleInput, $bodyInput);
setEditorVisible(false);
}
await loadNotes();
renderList();
await refreshStatus();
showToast('Note deleted', 'info');
}
async function handleNewNote() {
const note = await noteStore.save({ title: '', body: '' });
await loadNotes();
activeNoteId = note.id;
renderList();
populateEditor(note, $titleInput, $bodyInput);
setEditorVisible(true);
$titleInput.focus();
await refreshStatus();
showToast('New note created', 'success');
}
async function handleSave() {
if (!activeNoteId) return;
const existing = notes.find(n => n.id === activeNoteId);
if (!existing) return;
const updated = await noteStore.save({
...existing,
title: $titleInput.value,
body: $bodyInput.value,
});
await loadNotes();
renderList();
await refreshStatus();
// Keep the editor populated with the refreshed record
const refreshed = notes.find(n => n.id === updated.id);
if (refreshed) populateEditor(refreshed, $titleInput, $bodyInput);
}
function scheduleAutoSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => handleSave(), 800);
}
/* ------------------------------------------------------------------ */
/* Bootstrap */
/* ------------------------------------------------------------------ */
async function init() {
// Feature detection
if (!isSupported()) {
showUnsupportedBanner(true);
return;
}
showUnsupportedBanner(false);
try {
console.log('App: Entering storage init');
bucket = await initBucket(BUCKET_NAME);
console.log('App: Bucket initialized', bucket);
noteStore = await createNoteStore(bucket);
console.log('App: Store initialized', noteStore);
showUnsupportedBanner(false);
} catch (err) {
showToast(`Storage error: ${err.message}`, 'error');
console.error('App: Initialisation failure', err);
showUnsupportedBanner(true);
return;
}
await loadNotes();
renderList();
setEditorVisible(false);
await refreshStatus();
// Wire events
$btnNew.addEventListener('click', handleNewNote);
$btnSave.addEventListener('click', async () => {
await handleSave();
showToast('Note saved', 'success');
});
$titleInput.addEventListener('input', scheduleAutoSave);
$bodyInput.addEventListener('input', scheduleAutoSave);
// Keyboard shortcut: Ctrl/Cmd + S to save
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave().then(() => showToast('Note saved', 'success'));
}
});
// Keyboard shortcut: Alt + N for new note
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'n') {
e.preventDefault();
handleNewNote();
}
});
}
document.addEventListener('DOMContentLoaded', init);Result: In-Browser Storage Isolation
Once the app is running, you can inspect the storage in Chrome DevTools (Application > Storage > Storage Buckets).
When metadata and notes are saved, they are contained entirely within the notes bucket. If you check the IndexedDB section, you'll see it nested under this specific bucket, isolated from any other storage on the same origin.

How to Run This Locally
-
Prerequisites: Ensure you are using Chrome 122+ or Edge 122+.
-
Setup: Clone the Storage-Bucket-API-Example repository or replicate the folder structure above.
-
Local Server: The Storage Buckets API requires a Secure Context. Run a local server from the root directory:
Using Node.js:
npx serve .Using Python:
python -m http.server 8000 -
Inspect: Open DevTools, go to the Application tab, and look for the Storage Buckets tree item to see the API in action.
Why This Pattern Matters
By using a named bucket for our notes rather than dumping everything into the default storage origin, we gain three major advantages:
- Isolated Eviction: We marked our
notesbucket aspersisted: true. Even if other parts of this origin (like a separate image cache or session data) are being evicted by the browser due to storage pressure, our critical user notes are guarded. - Guaranteed Durability: By opting into
durability: 'strict', we tell the browser to prioritize data integrity over write performance. This is crucial for note-taking apps where a power failure during a save operation could otherwise corrupt the entire IndexedDB database. - Namespace Isolation: The IndexedDB
notes-dbexists purely inside thenotesbucket. This creates a clean namespace that doesn't collide with other databases on the same origin, making it easier to manage the lifecycle of different sets of data independently.
Where This Falls Short: The Limitations
Browser Support Is Chromium Only
As of March 2026, this API is supported in:
- Chrome 122+ and all Chromium-based browsers (Edge 122+, Opera 108+)
- Chrome for Android 145+, Samsung Internet 26+
And not supported in:
- Firefox (no signal of implementation)
- Safari (no signal of implementation)
That is roughly 71% of global browser usage according to caniuse, but it means you cannot use this as your only storage strategy. You need a fallback path that uses the default single-bucket model for unsupported browsers.
Policies Are Hints, Not Guarantees
When you pass persisted: true, the browser may decline. When you set a quota, the browser may ignore it. The spec is explicit: all policies are advisory. You must always verify after creation:
const bucket = await navigator.storageBuckets.open("critical-data", {
persisted: true,
durability: "strict",
quota: 50 * 1024 * 1024,
});
// All three of these could return values different from what you requested
const actualPersistence = await bucket.persisted();
const actualDurability = await bucket.durability();
const actualQuota = (await bucket.estimate()).quota;This makes Storage Buckets a preference system rather than a contract. Cloud storage buckets have SLAs with defined durability (typically 99.999999999%). Browser storage buckets have best-effort hints.
No Integration with DOM Storage
localStorage and sessionStorage are explicitly excluded from the Storage Buckets API. If you are using these APIs (and many apps still are), you cannot put them in a bucket. This is by design since DOM Storage does not follow the same async patterns, but it still limits the API's reach for apps that depend heavily on synchronous storage.
No Cross-Tab Coordination Built In
While Web Locks are available per bucket (bucket.locks), there is no built-in event system for coordinating bucket lifecycle across tabs. If Tab A deletes a bucket, Tab B's references to that bucket's IndexedDB connections will break with opaque errors. You need to build your own coordination layer using BroadcastChannel or shared workers.
Third-Party Context Behavior Is Undefined
The spec explicitly defers behavior in third-party (iframe) contexts. If your app is embedded in other sites, Storage Buckets behavior is not specified and may vary across browsers.
The Future of Storage Buckets
The API is in Chromium and shipping, but the spec is still evolving under the WICG. Here is what is on the horizon:
Service Worker Integration
The spec proposes letting you register service workers scoped to a bucket. When a bucket is evicted, its service workers are also unregistered. This pairs naturally with offline-first architectures: if the cached data is gone, there is no point waking up the service worker to serve it.
// Proposed API - not yet shipped
const registration = await inboxBucket.serviceWorker.register(
"/inbox-sw.js",
{ scope: "/inbox" }
);Clear-Site-Data Header Integration
The spec proposes extending the Clear-Site-Data HTTP header to target individual buckets:
Clear-Site-Data: "storage:user-session-42"
This would let servers trigger surgical cleanup of specific buckets, rather than the current all-or-nothing “storage” directive.
Broader Browser Adoption
The biggest question mark is whether Firefox and Safari will implement this API. Mozilla has not formally signaled opposition, but there has been no active development. Apple's WebKit team has been historically conservative about storage APIs. Without broader adoption, developers will continue needing polyfill strategies.
Potential Standardization
The API is currently a WICG draft. Moving it through the W3C standardization track would signal broader commitment and increase the likelihood of cross-browser support.
Feature Detection and Progressive Enhancement
Until the API has universal support, wrap it in feature detection:
async function getStorageBucket(name, options) {
if ("storageBuckets" in navigator) {
return navigator.storageBuckets.open(name, options);
}
// Fallback: return a shim that exposes the default storage APIs
return {
indexedDB: globalThis.indexedDB,
caches: globalThis.caches,
getDirectory: () => navigator.storage.getDirectory(),
estimate: () => navigator.storage.estimate(),
persisted: async () => navigator.storage.persisted(),
durability: async () => "strict",
};
}This shim gives you a single code path. On Chromium, you get real bucket isolation. On other browsers, everything falls back to the default bucket. Your app works everywhere, but works better on browsers that support the API.
Worth Watching, Worth Using Today
Storage Buckets solve a real, long-standing pain point in web storage management. The API is clean, the mental model is intuitive, and the integration with existing storage APIs means you are not relearning anything. You are just organizing what you already know.
The gap in browser support is real, and the advisory nature of policies means you cannot rely on it blindly. But for Chromium-targeted apps, PWAs, and offline-first architectures, this API is production-ready and delivers meaningful improvements to how you manage client-side data.
Start by auditing your current storage usage. Identify what is critical and what is expendable. Create buckets for each tier. Your users on low-storage devices will thank you when the browser evicts your thumbnail cache instead of their unsaved work.