Skip to content
Journey to Code

Mastering the Fetch API and Browser Storage

Modern Data Persistence in the Browser

Mastering Client-Side Storage

· By · Updated · 12 min read

Cover reading "Modern Data Persistence" with Cache API code beside a storage vault of five stores from localStorage to the Cache API

A web page forgets everything the moment you close the tab, unless it saves something in the browser. Save the wrong thing in the wrong place and you get lost data, a frozen page, or a stored password anyone can read. This article shows the four places a page can save things, and how to pick the right one.

In this article, we are going to save a small setting, a long list of records, and a copy of a web page for offline use. By the end, we will know which storage area fits each job.

Quick recap, in two lines: fetch() gives you a Response whose ok you must check, and a response body can be read only once (response.clone() makes a second copy). Both come back when we cache responses below.

The four shelves#

WhereHoldsGood forWatch out for
localStorage / sessionStorageText under a keyA theme, a small settingAbout 5 MB, blocks the page while it runs, text only
IndexedDBStructured records you can searchThousands of tasks, notes, messagesA wordy, event-based API
Cache APIWhole HTTP responsesPages and files for offline useYou decide when copies go stale
Origin Private File System (OPFS)Real filesBig binary data, such as mediaNot every browser supports every part

Cookies, the old option, hold about 4 KB each and travel to the server with every request, which is why these newer places exist.

localStorage and sessionStorage: the three-line API#

Web Storage is the simplest shelf. It holds text under a key, and it is synchronous (the page waits while it works):

localStorage.setItem('theme', 'dark');     // save
localStorage.getItem('theme');             // 'dark'
localStorage.removeItem('theme');          // delete

localStorage survives closing the browser. sessionStorage has the same three methods, but each tab gets its own copy. It survives a reload and is gone when the tab closes. Reach for either one for small settings and UI preferences.

The catch is "text only". Save an object and watch what you get back:

Coding challenge: Save an object properly

The starter stores an object directly. Run it, read what comes back, then fix the write so the name survives.

Expected result: the starter prints raw: [object Object] and name: undefined. After your fix it prints raw: {"name":"Ada"} and name: Ada.

Why getItem returns [object Object]
---js
// TODO: store the object as text with JSON.stringify
localStorage.setItem('user', { name: 'Ada' });

const saved = localStorage.getItem('user');
console.log('raw:', saved);

// TODO: turn the text back into an object with JSON.parse
console.log('name:', saved.name);

Storage has a ceiling too. Try the storage panel in the lab below: push values past the quota, and turn the JSON.stringify toggle off to see the same [object Object] again.

Fill the storage

Web Storage keeps strings. Saving an object without JSON.stringify stores the text [object Object], and reading it back gives you nothing useful. Saving too much text throws a quota error: in this lab the limit is 256 characters, while real browsers allow roughly 5 MB per origin.

Save a small object with the stringify option on, read it back and parse it, then save a long string and watch the quota error appear.

Browsers differ, but around 5 MB per origin is typical. When you go over, setItem throws a QuotaExceededError, so wrap writes that might be large in a try/catch. Because reads and writes block the page, avoid big or frequently changing data here.

Here is a small wrapper that adds expiry times and does the stringify and parse for you:

class EnhancedStorage {
  constructor(storageType = 'local') {
    this.storage = storageType === 'local' ? localStorage : sessionStorage;
  }

  // Store data with optional expiration
  setItem(key, value, expiresInMinutes = null) {
    const item = {
      value,
      timestamp: Date.now(),
      expiry: expiresInMinutes ? Date.now() + (expiresInMinutes * 60 * 1000) : null
    };

    try {
      this.storage.setItem(key, JSON.stringify(item));
      return true;
    } catch (error) {
      console.error('Storage error:', error); // for example QuotaExceededError
      return false;
    }
  }

  // Retrieve data if not expired
  getItem(key) {
    try {
      const itemStr = this.storage.getItem(key);
      if (!itemStr) return null;

      const item = JSON.parse(itemStr);
      if (item.expiry && Date.now() > item.expiry) {
        this.removeItem(key);
        return null;
      }
      return item.value;
    } catch (error) {
      console.error('Retrieval error:', error);
      return null;
    }
  }

  removeItem(key) {
    this.storage.removeItem(key);
  }
}

const userPrefs = new EnhancedStorage('local');
userPrefs.setItem('theme', 'dark');
userPrefs.setItem('draftNote', 'Buy milk', 60); // expires in 1 hour

Never keep secrets in Web Storage#

Any script running on your page can read everything in localStorage, including a script injected through a bug or a compromised library. So do not store session tokens, passwords or other secrets there. Encrypting them does not rescue you if the key sits in the page as well.

What encryption does help with is data a user protects with a passphrase that you never store. Here is a complete version using the Web Crypto API: a key is derived from the passphrase (deriveKey), then the data is locked with AES-GCM, and the random salt and IV travel with the result. It is for small values.

const encoder = new TextEncoder();
const decoder = new TextDecoder();

async function deriveKey(password, salt) {
  const material = await crypto.subtle.importKey(
    'raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
    material,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

async function encryptData(data, password) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const key = await deriveKey(password, salt);
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, encoder.encode(JSON.stringify(data))
  );

  // salt + iv + encrypted data, as one string localStorage can hold
  const bytes = new Uint8Array(28 + encrypted.byteLength);
  bytes.set(salt, 0);
  bytes.set(iv, 16);
  bytes.set(new Uint8Array(encrypted), 28);
  return btoa(String.fromCharCode(...bytes));
}

async function decryptData(text, password) {
  const bytes = Uint8Array.from(atob(text), c => c.charCodeAt(0));
  const key = await deriveKey(password, bytes.slice(0, 16));
  const plain = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: bytes.slice(16, 28) }, key, bytes.slice(28)
  );
  return JSON.parse(decoder.decode(plain));
}

With the right passphrase the value comes back. With a wrong one, decrypt throws an OperationError instead of returning garbage, because AES-GCM checks that the data was not changed.

Two cautions. 600,000 PBKDF2 iterations is the figure OWASP's password-storage guidance gives for SHA-256 as of October 2026, and that number moves up over time, so look it up before you copy it. And String.fromCharCode(...bytes) fails once the array gets very large, which is one more reason this is for small values.

IndexedDB: the database in your browser#

When the data outgrows a key and a string (thousands of records you want to filter by status or date), use IndexedDB. First the model, because the vocabulary is confusing without it:

  • A database has a name and a version number.
  • Inside it live object stores: think of each as a table, a box of records. Every record has a key.
  • You read and write inside a transaction: a group of operations that all succeed or all fail together.
  • Every operation is asynchronous. You start a request, and the browser calls your onsuccess or onerror function later.
  • Stores can only be created or changed in onupgradeneeded, a function that runs when you open the database with a higher version than the one that exists (the first time counts, as an upgrade from nothing).

IndexedDB also supports indexes (fast lookups by a field, like status), and can store binary data such as images.

The raw API is notoriously wordy, so here is a small wrapper around the pieces above:

class IndexedDBStore {
  constructor(dbName, version, storeConfigs) {
    this.dbName = dbName;
    this.version = version;
    this.storeConfigs = storeConfigs;
    this.db = null;
  }

  async open() {
    if (this.db) return this.db;

    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version);

      request.onupgradeneeded = (event) => {
        const db = event.target.result;

        // Create the object stores described in the configuration
        this.storeConfigs.forEach(config => {
          if (!db.objectStoreNames.contains(config.name)) {
            const store = db.createObjectStore(config.name, config.options);

            if (config.indexes) {
              config.indexes.forEach(idx => {
                store.createIndex(idx.name, idx.keyPath, idx.options);
              });
            }
          }
        });
      };

      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve(this.db);
      };

      // another tab still has an older version open, so the upgrade waits
      request.onblocked = () => console.warn('Close other tabs of this app to finish the upgrade');

      request.onerror = (event) => {
        reject(`IndexedDB error: ${event.target.error}`);
      };
    });
  }

  async add(storeName, item) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(storeName, 'readwrite');
      const request = transaction.objectStore(storeName).add(item);

      // resolve when the transaction is committed, not when the request succeeds
      transaction.oncomplete = () => resolve(request.result);
      // the failing request is event.target; transaction.error is still null at this point
      transaction.onerror = (event) => reject(event.target.error);
    });
  }

  // put: add a new record, or replace the one with the same key
  async put(storeName, item) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const transaction = db.transaction(storeName, 'readwrite');
      const request = transaction.objectStore(storeName).put(item);

      transaction.oncomplete = () => resolve(request.result);
      // the failing request is event.target; transaction.error is still null at this point
      transaction.onerror = (event) => reject(event.target.error);
    });
  }

  async getAll(storeName) {
    const db = await this.open();
    return new Promise((resolve, reject) => {
      const request = db.transaction(storeName, 'readonly')
        .objectStore(storeName)
        .getAll();

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

const taskDB = new IndexedDBStore('taskManager', 1, [
  {
    name: 'tasks',
    options: { keyPath: 'id', autoIncrement: true },
    indexes: [
      { name: 'by_status', keyPath: 'status' },
      { name: 'by_dueDate', keyPath: 'dueDate' }
    ]
  },
  {
    name: 'categories',
    options: { keyPath: 'id' }
  }
]);

async function addTask() {
  try {
    const id = await taskDB.add('tasks', {
      title: 'Learn IndexedDB',
      status: 'in-progress',
      dueDate: new Date(2026, 10, 30),
      categoryId: 2
    });
    console.log(`Task added with ID: ${id}`);
    console.log(await taskDB.getAll('tasks'));
  } catch (error) {
    console.error('Failed to add task:', error);
  }
}

The first add returns 1, and getAll returns the stored task. Use add for new records (it fails if the key already exists) and put when you may be replacing one. Both wait for the transaction to commit, so the data is safely stored by the time the promise resolves. Run it in a page served from localhost or HTTPS (not inside the little sandboxes on this page, which have no IndexedDB access).

Changing the shape later: versions and migrations#

When you ship a new feature that needs a new store or index, you bump the version number, and onupgradeneeded runs again. A migration is a small function for each version step. (The wrapper above builds its stores from a configuration, which is fine for a first version. Once an app is shipped, migrations are the safer way to change it, and they should build the same stores.) The trick is that the upgrade runs inside a transaction the browser gives you (event.target.transaction), and that is how you reach an existing store:

const dbMigrations = {
  1: (db) => {
    const tasks = db.createObjectStore('tasks', { keyPath: 'id', autoIncrement: true });
    tasks.createIndex('by_status', 'status');
    tasks.createIndex('by_dueDate', 'dueDate');
  },
  2: (db) => {
    db.createObjectStore('categories', { keyPath: 'id' });
  },
  3: (db, transaction) => {
    // db.transaction is a method for NEW transactions, not a way to reach this one
    transaction.objectStore('tasks').createIndex('by_tags', 'tags', { multiEntry: true });
  }
};

function applyMigrations(event) {
  const db = event.target.result;
  const transaction = event.target.transaction; // the upgrade transaction

  for (let version = event.oldVersion + 1; version <= event.newVersion; version++) {
    dbMigrations[version]?.(db, transaction);
  }
}

// Wire it in: this is the step that makes migrations actually run
const request = indexedDB.open('taskManager', 3);
request.onupgradeneeded = applyMigrations;

Note that db.transaction.objectStore('tasks') would fail, because db.transaction is a function that starts a new transaction. Use the upgrade transaction instead. Opening a fresh database at version 3 runs all three steps. Upgrading an existing version-1 database runs only steps 2 and 3.

Sooner or later you will want to know how much room is left:

async function checkStorageQuota() {
  if (navigator.storage?.estimate) {
    const { usage, quota } = await navigator.storage.estimate();

    console.log(`Using ${(usage / 1024 / 1024).toFixed(2)} MB`);
    console.log(`Quota: ${(quota / 1024 / 1024).toFixed(2)} MB`);
    console.log(`Utilization: ${(usage / quota * 100).toFixed(2)}%`);
    return { usage, quota };
  }

  console.warn('Storage estimation not supported');
  return null;
}

Chrome also adds a non-standard usageDetails field to the estimate (for example, how much of the usage is IndexedDB). Treat the numbers as estimates, not promises.

Cache API: whole responses, for offline use#

The Cache API stores network requests and their responses as pairs. It is made for files and pages, not for records. It is most useful with a service worker: a script the browser runs in the background, sitting between your page and the network, that sees every fetch your page makes and may answer from a cache instead.

You register a service worker once from your page. It needs HTTPS (or localhost while developing):

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

The basic operations are two calls, put (save a response) and match (find one):

async function cacheResource(url, response) {
  if (!response.ok) return; // never cache an error page
  const cache = await caches.open('v1');
  await cache.put(url, response);
}

async function fetchFromCache(url) {
  const cache = await caches.open('v1');
  return await cache.match(url);
}

That ok check is there for a reason. cache.put stores any response you give it, including a 404 page, so a broken answer could be served offline for weeks. (It does reject partial 206 responses.)

The interesting part is the strategy: which do you try first, the cache or the network?

Cache-first#

Check the cache, and only go to the network on a miss. Fast, but the copy can be stale:

async function cacheFirst(request) {
  const cache = await caches.open('v1');
  const cached = await cache.match(request);
  if (cached) return cached;

  const fetched = await fetch(request);
  if (fetched.ok) await cache.put(request, fetched.clone());
  return fetched;
}

Network-first#

Try the network, and use the cache only if the network fails. Fresh when online, still works offline. Give the network a deadline, because a bad connection can hang for a long time before it fails, and the cached copy sits there waiting:

async function networkFirst(request) {
  const cache = await caches.open('v1');

  try {
    const fetched = await fetch(request, { signal: AbortSignal.timeout(3000) });
    if (fetched.ok) await cache.put(request, fetched.clone());
    return fetched;
  } catch (error) {
    const cached = await cache.match(request);
    if (cached) return cached;
    throw error;
  }
}

Predict the offline result#

The sandbox below has no Cache API or network, so a Map plays the cache and a flag plays the connection. Before you run it, write down what each of the four lines will print, assuming the cache holds last week's tasks and the server has today's.

Coding challenge: Predict, then change the cache

Expected result (with last week's data cached): cache-first returns tasks from last week both online and offline; network-first returns tasks from today online and tasks from last week offline. Then change new Map([['/api/tasks', 'tasks from last week']]) to new Map() (an empty cache) and predict again: cache-first online fetches tasks from today, and both strategies fail offline.

Cache-first or network-first?
---js
const startCache = () => new Map([['/api/tasks', 'tasks from last week']]); // TODO: try new Map()
let cache = startCache();
let online = true;

async function network(url) {
  if (!online) throw new TypeError('Failed to fetch');
  return 'tasks from today';
}

async function cacheFirst(url) {
  if (cache.has(url)) return cache.get(url);
  const data = await network(url);
  cache.set(url, data);
  return data;
}

async function networkFirst(url) {
  try {
    const data = await network(url);
    cache.set(url, data);
    return data;
  } catch (error) {
    if (cache.has(url)) return cache.get(url);
    throw error;
  }
}

async function run(label, strategy, isOnline) {
  cache = startCache();
  online = isOnline;
  try { console.log(label, '->', await strategy('/api/tasks')); }
  catch (error) { console.log(label, '-> error:', error.message); }
}

async function main() {
  await run('cache-first,   online ', cacheFirst, true);
  await run('cache-first,   offline', cacheFirst, false);
  await run('network-first, online ', networkFirst, true);
  await run('network-first, offline', networkFirst, false);
}
main();
The cache holds last week's copy of /api/tasks and the network is working. What does a cache-first strategy return?
A user is offline and opens a page that uses network-first. A copy was cached last week. What happens?

Stale-while-revalidate#

Return the cached copy immediately, and refresh the cache in the background so the next visit is fresh. Check ok before caching, and catch the failure of the background fetch. When offline it rejects with nobody listening, which is an unhandled rejection even though the user got the cached page:

async function staleWhileRevalidate(request) {
  const cache = await caches.open('v1');
  const cached = await cache.match(request);

  const refresh = fetch(request)
    .then(response => {
      if (response.ok) cache.put(request, response.clone());
      return response;
    })
    .catch(() => undefined); // offline: keep the cached copy, no unhandled rejection

  return cached || (await refresh) || Response.error();
}

Inside a service worker, also hand the refresh promise to event.waitUntil(refresh), so the browser keeps the worker alive until the cache is updated. The service worker in the next article does that.

Combine the strategies per kind of resource inside the service worker:

// Service worker fetch handler with resource-specific strategies
self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  if (url.pathname.startsWith('/api/')) {
    event.respondWith(networkFirst(event.request));        // data: freshness matters
  } else if (url.pathname.endsWith('.jpg') || url.pathname.endsWith('.png')) {
    event.respondWith(cacheFirst(event.request));          // images rarely change
  } else if (url.pathname.startsWith('/content/')) {
    event.respondWith(staleWhileRevalidate(event.request)); // fast now, fresh next time
  } else {
    event.respondWith(networkFirst(event.request));
  }
});

Precaching and cleaning up old caches#

"There are only two hard things in computer science: cache invalidation and naming things." Versioned cache names make invalidation manageable: bump the name, and delete every other cache when the new service worker activates. Doing the cleanup in the activate event means the new cache is already filled (in install) before the old one disappears:

const CACHE = 'v2';

// install: copy the files you need offline into the new cache
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE).then(cache => cache.addAll([
      '/', '/styles.css', '/app.js', '/offline.html'
    ]))
  );
});

// activate: the new version takes over, so delete every other cache
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(names =>
      Promise.all(names.filter(name => name !== CACHE).map(name => caches.delete(name)))
    )
  );
});

// fetch: when a page request fails, use the copy we cached, or else the offline page
self.addEventListener('fetch', event => {
  if (event.request.mode !== 'navigate') return;
  event.respondWith(
    fetch(event.request).catch(async () =>
      (await caches.match(event.request)) || caches.match('/offline.html')
    )
  );
});

cache.addAll fails as a whole if any one file answers with an error, which is a useful warning during development.

The part that trips everyone up is the update. A new service worker installs, then waits until every tab using the old one is closed. Reloading is not enough. Calling self.skipWaiting() in install lets it activate at once, and clients.claim() in activate lets it take over open pages. Both are safe here because the new cache is filled before the old one is deleted. In the browser's developer tools, the Application panel's Service Workers section shows the worker that is waiting.

Which store should I use?#

Rules of thumb: a single small value goes in Web Storage. Many records you search or sort go in IndexedDB. Files and pages fetched from your server, to be shown offline, go in the Cache API. Big binary data you edit goes in OPFS. Practice the rule:

You want to remember a visitor's dark-mode choice (one true/false value). Which store fits best?
A task app keeps 5,000 tasks and must list the ones with status "done". Which store fits best?
Your site's logo image and your offline.html page are files fetched from your server, and you want them to show without a network. Which store fits best?

Optional: files, persistence and newer APIs#

Everything above covers most apps. The rest is for specialized cases, and some of it is not available in every browser.

Origin Private File System#

The Origin Private File System (OPFS) gives each origin a private folder of real files that the user never sees. It suits large binary data, such as media and databases, and is the way to get file-like speed in the browser (the fastest access uses a synchronous handle inside a Web Worker). Support differs by browser and by method, so feature-detect navigator.storage.getDirectory. MDN lists createWritable(), used below, as newly available across browsers only since September 2025, so older Safari versions may lack it.

async function getOPFS() {
  return navigator.storage.getDirectory(); // the root folder of this origin's files
}

async function writeFile(fileName, content) {
  const root = await getOPFS();
  const fileHandle = await root.getFileHandle(fileName, { create: true });

  const writable = await fileHandle.createWritable();
  await writable.write(content);
  await writable.close();

  return fileHandle;
}

async function readFile(fileName) {
  const root = await getOPFS();
  const fileHandle = await root.getFileHandle(fileName);
  const file = await fileHandle.getFile();
  return await file.text();
}

Binary data works the same way: write the Blob from a canvas.

async function saveCanvasToOPFS(canvas, fileName) {
  const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));

  const root = await getOPFS();
  const fileHandle = await root.getFileHandle(fileName, { create: true });
  const writable = await fileHandle.createWritable();

  await writable.write(blob);
  await writable.close();

  return fileHandle;
}

A common way to process such files is WebAssembly (compiled code that runs in the browser). One thing to know: a WebAssembly function can only be called with numbers. Passing a file's ArrayBuffer does not hand it the bytes: a function declared to take one number turns an ArrayBuffer into 0. To process bytes you copy them into the module's own memory and pass a number that says where they are. A file handle also has no .parent property; keep the directory handle you got the file from.

Persistence and the Storage Buckets API#

By default a browser may evict your data when the disk is nearly full. You can ask it not to:

async function requestPersistence() {
  if (navigator.storage?.persist) {
    const isPersisted = await navigator.storage.persist();
    console.log(`Persistence status: ${isPersisted ? 'granted' : 'denied'}`);
    return isPersisted;
  }
  return false;
}

The browser decides whether to say yes, and may say no. Firefox asks the user. Chrome and Safari decide on their own, from how the visitor has used the site (MDN, as of October 2026). There is no storagepressure event to listen for. Instead, check estimate() yourself now and then, and handle QuotaExceededError when a write fails.

One more caution about "persistent". Safari, with its cross-site tracking prevention on, deletes script-written data (Web Storage, IndexedDB, the Cache API) for a site the visitor has not interacted with in seven days of browser use (MDN, as of October 2026). Treat anything stored on the device as a copy, and keep the real data on a server.

The Storage Buckets API lets one origin split its data into named containers that the browser can clear separately. As of October 2026 it works in Chromium browsers only, so treat it as an extra and not something to build on.

async function openDraftsBucket() {
  if (!('storageBuckets' in navigator)) throw new Error('Storage Buckets API not supported');

  const bucket = await navigator.storageBuckets.open('drafts');
  // Each bucket has its own IndexedDB and Cache storage
  return { db: bucket.indexedDB, caches: bucket.caches };
}

Practical Challenge: A task list that survives a reload#

Build it in three stages, running each one before starting the next.

Stage 1: Expire old drafts (in the sandbox). Finish load so that an expired item is removed and returns null. The clock is a parameter, so you can test without waiting.

Expected result: the console prints Buy milk, then null, then null (the key is gone from storage).

Stage 1: expiry
---js
function save(key, value, ttlMs, now = Date.now()) {
  localStorage.setItem(key, JSON.stringify({ value, expiresAt: now + ttlMs }));
}

function load(key, now = Date.now()) {
  const raw = localStorage.getItem(key);
  if (raw === null) return null;
  const item = JSON.parse(raw);
  // TODO: if now is later than item.expiresAt, remove the key and return null
  return item.value;
}

save('draft', 'Buy milk', 60000, 0);
console.log(load('draft', 30000)); // expected: Buy milk
console.log(load('draft', 90000)); // expected: null
console.log(localStorage.getItem('draft')); // expected: null

Stage 2: Keep the real list in IndexedDB (in a page on localhost). Copy the IndexedDBStore class and the taskDB setup from above into a page served from localhost. Add three tasks with taskDB.add, reload the page, and print await taskDB.getAll('tasks'). You should see all three. In the browser's developer tools, the Application panel shows the taskManager database with its tasks store.

Stage 3: Make the page open offline. Add the install, activate and fetch handlers from the precaching section as /sw.js, register it, load the page once, then switch the developer tools to "Offline" and reload. You should see your page, or offline.html for pages you did not cache.

You now have the pieces. What they do not have is a plan for the moment the tasks change in two places, offline: a task edited on this device and again on the server. That is the next article.

Conclusion#

The browser gives you a shelf for every kind of data: Web Storage for a flag, IndexedDB for records, the Cache API for pages and files, and OPFS for big binary data. Choosing the right one keeps your page fast and your data safe, and none of them is a place for secrets.

All of it so far lives on one device. In the next article we make that data safe to change while offline: queue the changes you make, send them when the connection returns, and decide who wins when the server has a different version. (We touch on CRDTs and operational transforms there only as names for harder problems; the article builds the simpler tools first.)

Comments require JavaScript.