Your project works: the domain is green, the first vertical slice is visible in the browser and rules R1–R15 are enforced. And yet there is a lie in play, because when you reload the page everything disappears. The in-memory repository did exactly what it had to do — not block you while you built what mattered — and now it is time to swap it for something real, without touching a single line of view. That is the acid test of the contract you defined in the previous lesson. But persisting is not just calling localStorage.setItem: it is deciding which storage fits your case, versioning the format you save so you can change it three months from now without losing anyone's data, talking to an API in a way that makes network failures part of the design rather than a surprise, deciding who wins when two people edit the same thing, allowing work offline with a queue of changes that resends itself, and making the application feel instant with optimistic updates that roll back if the server says no. And there is a part that is not technical and is nonetheless mandatory: what must never be in the browser, and what storing people's data means legally. By the end you will have persistence with tested migrations, an API layer with its states, and a working offline queue.

Contents

  1. Choosing storage to fit the case
  2. When localStorage is not enough
  3. IndexedDB at a practical level
  4. A minimal promise wrapper
  5. Versioning the stored format
  6. Numbered migrations and their tests
  7. The repository as a boundary: one interface, three implementations
  8. Talking to the API: robust fetch
  9. The states of an asynchronous operation
  10. Retries, cancellation and AbortController
  11. Synchronization: who wins when two people edit at once
  12. The pending-changes queue
  13. Idempotency and safe resending
  14. Optimistic updates with rollback
  15. Real time without duplicating your own changes
  16. Security and privacy of what you store
  17. Synchronization failures and how to handle them
  18. Common Mistakes and Tips
  19. Exercises
  20. Conclusion

  1. Choosing storage to fit the case

Lesson 07-01 introduced the browser's storage options. Here the table comes back with the column that did not matter then and does now: when to choose each one for your project.

Option Typical capacity Synchronous Structured Persists When you pick it
In-memory variables RAM Yes Yes Session only Interface state that must not survive a reload
sessionStorage ~5 MB Yes No (text only) Until the tab closes Data for a flow in progress: a long form halfway through
localStorage ~5–10 MB Yes No (text only) Until it is cleared Preferences, and small, stable data sets
IndexedDB Hundreds of MB to GB No (promises) Yes (objects, indexes) Until it is cleared Large volumes, index queries, binary data
Cache Storage Hundreds of MB No Requests and responses Until it is cleared Application resources: the service worker from 07-05
Cookies ~4 kB Yes No Configurable Only session identification with the server
Remote API Unlimited No Yes Always Multi-device, multi-user, data that genuinely matters

Two warnings that change decisions:

localStorage is synchronous, and that means it blocks the main thread. Saving 2 MB of JSON can cost tens of milliseconds during which the interface does not respond. With the INP ≤ 200 ms budget from 11-01, that matters. IndexedDB is asynchronous and does not block.

Nothing in the browser is private. Anyone with access to the device, and any script running on your page, can read all of it. We will come back to this in section 16, but keep it in mind already when deciding what you store.

The decision tree for your project:

flowchart TD
    A["Does the data need to be seen<br/>on another device?"] -->|Yes| B["Remote API<br/>+ local cache"]
    A -->|No| C["How big is it<br/>in the worst case?"]
    C -->|"< 1 MB and stable"| D["localStorage<br/>with migrations"]
    C -->|"> 1 MB or grows without limit"| E["IndexedDB"]
    C -->|"I don't know"| F["Measure it with realistic<br/>data BEFORE deciding"]
    F --> C
    E --> G["Do you need to query<br/>by something other than the id?"]
    G -->|Yes| H["IndexedDB with indexes"]
    G -->|No| I["IndexedDB as a<br/>key-value store"]

    style D fill:#dcfce7,stroke:#16a34a
    style B fill:#dbeafe,stroke:#2563eb
    style F fill:#fef3c7,stroke:#d97706

The orange node is the important one. "I don't know" is the honest answer at the start, and the way out is not to guess: it is to measure with realistic data. Generate 500 tasks, 3,000 history entries and 10 fictional users, serialize it and look at how big it is:

// A thirty-second calculation that prevents a wrong decision
const data = generateRealisticData({ tasks: 500, history: 3000, users: 10 });
const text = JSON.stringify(data);
console.log('Size:', (new Blob([text]).size / 1024).toFixed(1), 'kB');
console.time('serialize'); JSON.stringify(data); console.timeEnd('serialize');

For Orbita, at that volume, the result comes out around 900 kB and serialization around 12 ms. Conclusion: localStorage is good enough for the MVP, on two conditions — that the history gets pruned (section 2) and that writes do not happen on every keystroke.

  1. When localStorage is not enough

It has five limits, and it is worth recognizing them before you hit them:

Limit Symptom When it shows up
Quota (~5–10 MB) QuotaExceededError on save When the history (R14) has been growing for a few months
Synchronous The interface freezes on save Over ~1 MB, or when saving very often
Text only JSON.parse on every read CPU cost proportional to the total, even if you only want one task
All or nothing You have to read and write the whole document Changing one task rewrites all 500
No queries Filtering requires loading everything into memory Always, though at small volumes you do not notice

The most dangerous is the first, because it fails in production and on somebody else's device, not on yours. And handling it correctly is not an empty try/catch:

// src/data/local-repository.js
async saveAll(doc) {
  const text = JSON.stringify(doc);
  try {
    localStorage.setItem(this.key, text);
  } catch (error) {
    if (isQuotaError(error)) {
      // 1 · Prune what can be pruned: the old history
      const pruned = pruneHistory(doc, { keep: 200 });
      try {
        localStorage.setItem(this.key, JSON.stringify(pruned));
        this.notices.emit('history-pruned', { removed: howMany });
        return;
      } catch { /* still does not fit */ }
    }
    // 2 · If it does not fit even pruned, IT IS A USER-FACING ERROR AND YOU MUST SAY SO
    throw new DataError(
      'There is no space left to save. Export your data and free up space.',
      { cause: error, recoverable: false }
    );
  }
}

function isQuotaError(error) {
  return error instanceof DOMException &&
    (error.name === 'QuotaExceededError' ||
     error.name === 'NS_ERROR_DOM_QUOTA_REACHED');  // older Firefox
}

Three decisions in that fragment:

  • It tries to prune before giving up. The history is the only expendable thing; the tasks never are.
  • The pruning is announced. Silently deleting user data is unacceptable, even secondary data.
  • If it does not fit, it throws with an actionable message. "Error saving" does not help; "export your data and free up space" does. And recoverable: false tells the interface not to offer a retry button that would just fail again.

The check almost nobody does: in some browsers' private mode, and with certain blocking configurations, localStorage exists but throws on write. Check it at startup and degrade gracefully:

export function storageAvailable() {
  try {
    const probe = '__orbita_test__';
    localStorage.setItem(probe, '1');
    localStorage.removeItem(probe);
    return true;
  } catch { return false; }
}

If it returns false, the application must keep working in memory and clearly warn that changes will not be saved. It is the difference between a broken application and an honest one.

  1. IndexedDB at a practical level

IndexedDB is the browser's database. It has a reputation for being awkward and it deserves it: its native API is from 2010, event-based and verbose. But the part you need is small.

The mental model, in four concepts:

Concept Equivalent In Orbita
Database A database orbita
Object store A table tasks, users, history, queue
Key Primary key The task's id
Index Column index byAssignee, byDueDate

And four operating rules you have to understand before using it:

  1. Everything happens inside a transaction, which can be readonly or readwrite.
  2. Transactions close themselves as soon as the event loop has no pending work for them. If you await something unrelated in the middle, the transaction dies. It is the number one source of baffling errors.
  3. The schema is only changed in onupgradeneeded, which fires when you open with a higher version number. It is the equivalent of the migration in section 6, but for the structure.
  4. It stores objects, not text. It uses the structured clone algorithm, so it accepts Date, Map, Set, ArrayBuffer… but not functions or class instances with their methods. Store plain objects and rebuild the entities on read, exactly as with fromJSON.

When to move from localStorage to IndexedDB, with objective criteria:

Signal Threshold
The serialized document exceeds ~2 MB
Save time exceeds ~16 ms (one frame)
You need to read part of it without loading the whole Always
You store binary data (images, attachments) Always
You need to query by something other than the key Always

For the Orbita MVP, at an estimated 900 kB, it is not needed. And that is also a defensible decision that deserves its ADR: "localStorage is chosen because the expected volume is an order of magnitude below the limit, and the repository boundary allows switching to IndexedDB without touching anything else".

  1. A minimal promise wrapper

If your project does need IndexedDB, do not use the native API directly in your repository: wrap it once, in one file, and forget about it.

// src/data/idb.js — minimal promise wrapper
export function open(name, version, onUpgrade) {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(name, version);
    request.onupgradeneeded = (e) => onUpgrade(e.target.result, e.oldVersion, e.newVersion);
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(new DataError('Could not open the database', { cause: request.error }));
    request.onblocked = () => reject(new DataError('Another tab has an older version open'));
  });
}

function promiseFrom(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

export async function readAll(db, storeName) {
  const tx = db.transaction(storeName, 'readonly');
  return promiseFrom(tx.objectStore(storeName).getAll());
}

export async function writeBatch(db, storeName, objects) {
  const tx = db.transaction(storeName, 'readwrite');
  const store = tx.objectStore(storeName);
  // CAREFUL: no unrelated await in here, or the transaction closes
  for (const object of objects) store.put(object);
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted'));
  });
}

Four points that explain why this wrapper looks the way it does:

1 · promiseFrom turns the event pattern into a promise. It is exactly the "promisification" technique from 05-06: a function that wraps a callback API in a new Promise. Written once, it serves every operation.

2 · onblocked is handled. It happens when the user has two tabs open and one tries to upgrade the schema while the other is using the old version. Ignoring it produces a silent hang that is extremely hard to diagnose.

3 · The comment about await is not decoration. This is the classic trap:

// ❌ The transaction dies halfway
const tx = db.transaction('tasks', 'readwrite');
for (const task of tasks) {
  const validated = await validateOnServer(task);   // ← unrelated await: tx closes
  tx.objectStore('tasks').put(validated);           // ← TransactionInactiveError
}

// ✅ Prepare everything first, write afterwards
const validated = await Promise.all(tasks.map(validateOnServer));
const tx = db.transaction('tasks', 'readwrite');
for (const t of validated) tx.objectStore('tasks').put(t);

4 · You wait for oncomplete, not for the last request. The last write succeeding does not mean the transaction was committed. Only oncomplete guarantees the data is on disk.

  1. Versioning the stored format

Here is the section that separates a toy project from a serious one, and it is worth saying without hedging:

The day you change the data model, users will already have data saved in the old format. If you have not planned for it, you lose it.

In Nómada Tasks, the key was 'nomada:board:v1'. That v1 was the seed of this idea. Now it becomes a complete mechanism.

The stored document is never the plain list of tasks. It is an envelope with metadata:

{
  "version": 3,
  "savedAt": "2026-09-20T18:42:11.320Z",
  "app": "orbita",
  "data": {
    "tasks": [],
    "users": [],
    "history": []
  }
}
Field What for
version The only indispensable one. It says which migrations to apply
savedAt Debugging and timestamp-based conflict resolution
app Detecting that something else wrote the key; prevents corrupting other data
data The actual content, always nested, never at the root

That nesting matters: if the data sits at the root next to version, adding a new metadata field can collide with an entity. With data kept separate, the envelope and the content evolve independently.

The version rule: the number only goes up, one at a time, and every increase has its migration. A number is never reused, not even during development — because your own development browser already has data from the previous version, and that is where you will find the migration bugs before anybody else.

  1. Numbered migrations and their tests

A migration is a pure function that transforms the document from version N to N+1.

// src/data/migrations.js
export const MIGRATIONS = [
  {
    to: 1,
    description: 'Initial format',
    migrate: (doc) => doc
  },
  {
    to: 2,
    description: 'assignee (text) becomes assigneeId (reference)',
    migrate: (doc) => {
      const byName = new Map(doc.data.users.map((u) => [u.name, u.id]));
      return {
        ...doc,
        data: {
          ...doc.data,
          tasks: doc.data.tasks.map(({ assignee, reviewer, ...rest }) => ({
            ...rest,
            assigneeId: assignee ? (byName.get(assignee) ?? null) : null,
            reviewerId: reviewer ? (byName.get(reviewer) ?? null) : null
          }))
        }
      };
    }
  },
  {
    to: 3,
    description: 'Add parentTaskId and createdAt to existing tasks',
    migrate: (doc) => ({
      ...doc,
      data: {
        ...doc.data,
        tasks: doc.data.tasks.map((t) => ({
          ...t,
          parentTaskId: t.parentTaskId ?? null,
          createdAt: t.createdAt ?? doc.savedAt ?? '2026-01-01T00:00:00.000Z'
        }))
      }
    })
  }
];

export const CURRENT_VERSION = MIGRATIONS.at(-1).to;

export function migrate(document) {
  let doc = document;
  const from = doc.version ?? 0;

  if (from > CURRENT_VERSION) {
    throw new DataError(
      `The data is from a newer version (${from}) than this application (${CURRENT_VERSION}). ` +
      'Update the application to be able to open it.'
    );
  }

  for (const step of MIGRATIONS) {
    if (step.to <= from) continue;
    doc = { ...step.migrate(doc), version: step.to };
  }
  return doc;
}

Six properties of this design, and why each one matters:

1 · Migrations are pure functions. They take a document and return another one. They neither read nor write localStorage. That is why they can be tested with an object literal, without setting anything up.

2 · They are applied in a chain. A user who abandoned the application at version 1 and comes back today goes through 1→2 and 2→3 automatically. There is no need for a "1 to 3" migration.

3 · Every migration has its description. It is documentation that lives next to the code and shows up in the log when it is applied.

4 · A future version is rejected with a clear message. It genuinely happens: the user has two devices and one updated first. Trying to read a future format and "make do" corrupts the data; refusing and explaining does not.

5 · The defaults are conservative. createdAt uses savedAt if it exists, and only falls back to a fixed date if there is nothing. Inventing new Date() would make every old task look like it was created today, breaking R4 and the history ordering.

6 · Migration 2 uses the byName index. And when a name is not among the users, it sets null instead of failing. That is the right decision: losing an assignment is bad, but not being able to open the application is worse.

6.1 How migrations are tested

This is the part almost nobody does and the one that averts disaster. The technique: save real documents from every old version as test fixtures.

test/data/fixtures/
  document-v1.json       ← copied verbatim from a real v1 localStorage
  document-v2.json
  document-v1-empty.json
  document-v1-corrupt.json
// test/data/migrations.test.js
import { migrate, CURRENT_VERSION } from '../../src/data/migrations.js';
import v1 from './fixtures/document-v1.json';
import v2 from './fixtures/document-v2.json';

describe('Migrations', () => {
  test.each([
    ['v1', v1],
    ['v2', v2]
  ])('%s migrates to the current version without losing tasks', (name, original) => {
    const result = migrate(structuredClone(original));

    expect(result.version).toBe(CURRENT_VERSION);
    expect(result.data.tasks).toHaveLength(original.data.tasks.length);
  });

  test('v1: every assignee with a known name keeps their assignment', () => {
    const result = migrate(structuredClone(v1));
    const original = v1.data.tasks.find((t) => t.assignee === 'Marta');
    const migrated = result.data.tasks.find((t) => t.id === original.id);
    expect(migrated.assigneeId).toBe('u-marta');
    expect(migrated).not.toHaveProperty('assignee');   // the old field goes away
  });

  test('an unknown assignee becomes null, it does not break the migration', () => {
    const withGhost = structuredClone(v1);
    withGhost.data.tasks[0].assignee = 'Person Who Does Not Exist';
    expect(() => migrate(withGhost)).not.toThrow();
    expect(migrate(withGhost).data.tasks[0].assigneeId).toBeNull();
  });

  test('the migration result is valid for the domain', () => {
    const result = migrate(structuredClone(v1));
    for (const plain of result.data.tasks) {
      expect(() => Task.fromJSON(plain)).not.toThrow();
    }
  });

  test('migrating is idempotent: applying it twice changes nothing', () => {
    const once = migrate(structuredClone(v1));
    const twice = migrate(structuredClone(once));
    expect(twice).toEqual(once);
  });

  test('a future version is rejected with an explanatory message', () => {
    expect(() => migrate({ version: 99, data: {} })).toThrow(/newer version/);
  });
});

The six tests cover the six possible failures, and the fourth and fifth are the most valuable:

  • "The result is valid for the domain" is the one that really closes the loop. A migration can produce a document that is syntactically correct and semantically invalid — a task with no title, hours at 0 — that will blow up when the entity is built. Validating every migrated object against the domain catches it right there.
  • Idempotency protects against the most common migration failure: applying them twice because of a flow bug. If migrate(migrate(x)) === migrate(x), that bug is harmless.

And the golden operational rule: before applying migrations to real data, make a backup.

async function loadWithMigration() {
  const raw = localStorage.getItem(KEY);
  if (!raw) return emptyDocument();

  const doc = JSON.parse(raw);
  if (doc.version === CURRENT_VERSION) return doc;

  // Backup BEFORE touching anything
  localStorage.setItem(`${KEY}:backup:v${doc.version}`, raw);
  try {
    const migrated = migrate(doc);
    localStorage.setItem(KEY, JSON.stringify(migrated));
    return migrated;
  } catch (error) {
    log(error, { useCase: 'migration', from: doc.version });
    throw new DataError(
      'Your data could not be updated. A backup has been kept.',
      { cause: error, recoverable: false }
    );
  }
}

The backup costs one line and turns an irreversible disaster into a recoverable incident. In lesson 11-04 you will debug precisely a migration that corrupts data, and that copy is what will let you investigate.

  1. The repository as a boundary: one interface, three implementations

Now the previous lesson's investment pays off. The contract in src/data/repository.js does not change; two more implementations appear:

flowchart LR
    A["application/<br/>use cases"] --> C{{"Repository contract<br/>listTasks, saveTask,<br/>deleteTask, addChange…"}}
    C --> M["MemoryRepository<br/><i>tests, startup</i>"]
    C --> L["LocalRepository<br/><i>localStorage + migrations</i>"]
    C --> P["ApiRepository<br/><i>fetch + retries</i>"]
    C --> S["SyncedRepository<br/><i>local + api + queue</i>"]

    style C fill:#f3e8ff,stroke:#9333ea
    style S fill:#dbeafe,stroke:#2563eb

And the same battery of contract tests runs against all four:

// test/data/repositories.test.js
import { contractTests } from './repository-contract.js';

contractTests('memory', async () => new MemoryRepository());

contractTests('local', async () => {
  localStorage.clear();
  return new LocalRepository('orbita:test');
});

contractTests('api', async () => {
  fakeServer.reset();
  return new ApiRepository('http://localhost:3001');
});

If all three pass the same tests, they are substitutable, and switching storage is a one-line change in main.js:

// src/main.js
const repo = import.meta.env.VITE_SOURCE === 'api'
  ? new ApiRepository(import.meta.env.VITE_API_URL)
  : new LocalRepository('orbita:board');

This is what lesson 08-04 called dependency injection, and here you see its full value: the same design decision that made testing with doubles possible makes changing storage technology possible. They are not two benefits: they are the same one, seen from two places.

SyncedRepository is the one you will build in sections 12 to 14: it combines local (fast, always available) with the API (shared, authoritative) and a queue for whatever could not be sent. And it satisfies the same contract, so the application never notices the difference.

  1. Talking to the API: robust fetch

If your project is going to talk to a server, the network layer is built once and built properly. It is what js/data/http.js did in Nómada Tasks with fetchJson, ApiError and withRetries, and it is worth rebuilding it while understanding each decision.

// src/data/http.js
export class ApiError extends Error {
  constructor(message, { status, code, body } = {}) {
    super(message);
    this.name = 'ApiError';
    this.status = status ?? 0;
    this.code = code ?? null;
    this.body = body ?? null;
  }
  get retryable() {
    // 0 = network failure. 408 timeout. 429 too many requests. 5xx server.
    return this.status === 0 || this.status === 408 || this.status === 429 || this.status >= 500;
  }
  get isClientError() { return this.status >= 400 && this.status < 500; }
}

export async function fetchJson(url, options = {}) {
  const { timeout = 8000, signal, ...rest } = options;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort('timeout'), timeout);
  signal?.addEventListener('abort', () => controller.abort(signal.reason), { once: true });

  try {
    const response = await fetch(url, {
      ...rest,
      signal: controller.signal,
      headers: { 'Content-Type': 'application/json', ...rest.headers }
    });

    if (!response.ok) {
      const body = await safeReadBody(response);
      throw new ApiError(body?.message ?? `Error ${response.status}`, {
        status: response.status,
        code: body?.code,
        body
      });
    }
    return response.status === 204 ? null : response.json();
  } catch (error) {
    if (error instanceof ApiError) throw error;
    if (error.name === 'AbortError') {
      throw new ApiError('The request took too long', { status: 408 });
    }
    throw new ApiError('No connection to the server', { status: 0 });
  } finally {
    clearTimeout(timer);
  }
}

The seven points that make this function robust, all introduced in 07-03:

  1. fetch does not throw on 404 or 500. It only throws if the network fails. Without the response.ok check, a 500 would be processed as if it were a success with a strange body. It is the number one mistake with fetch.
  2. An explicit timeout. fetch has no default timeout: a request can hang indefinitely and take your loading indicator with it.
  3. The external signal is propagated. It lets the caller cancel (section 10) in addition to the internal timer.
  4. The error body is read. APIs return useful information in the body of a 400: which field failed and why. Discarding it forces you to show "Error 400" to a user who can do nothing with that.
  5. safeReadBody wraps the json() in a try/catch, because a server error may return HTML instead of JSON, and then json() throws and hides the original error.
  6. 204 returns null. No content means no content; calling json() on an empty body throws.
  7. Every error ends up as an ApiError. The upper layer handles one type, not five. That massively simplifies the catch in the use cases.

And withRetries, with exponential backoff and jitter:

export async function withRetries(fn, { attempts = 3, base = 300, signal } = {}) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (error) {
      const last = i === attempts - 1;
      if (last || !(error instanceof ApiError) || !error.retryable) throw error;
      const wait = base * 2 ** i + Math.random() * 200;   // 300, 600, 1200 ms + noise
      await sleep(wait, signal);
    }
  }
}

Only the retryable is retried. Retrying a 400 ("the title is missing") is pointless: the result will be identical all three times, and you will have tripled the user's wait before showing them an error that was already known on the first attempt.

Jitter stops all the clients from retrying in the same millisecond when the server goes down and comes back, taking it down again. With a single user it makes no difference; it is one of those things that cost one line and are appreciated when there are a thousand.

  1. The states of an asynchronous operation

A network operation does not have two outcomes, it has four states, and the interface has to be able to render all of them:

stateDiagram-v2
    [*] --> Idle
    Idle --> Loading: request is fired
    Loading --> Success: 2xx
    Loading --> Error: 4xx, 5xx or network
    Loading --> Canceled: AbortController
    Error --> Loading: retry
    Success --> Loading: reload
    Canceled --> Idle
State What is shown Typical bug if ignored
Idle Nothing, or the empty state
Loading Skeleton or indicator + aria-busy="true" Double submit out of impatience
Success The data, announced if it changed
Error A comprehensible message + an action ("Retry") A blank screen with no explanation
Canceled Back to the previous state, no error An error message for something the user canceled

Two implementation details that make the difference:

Disable the trigger while loading. A "Save" button that stays clickable during the two seconds of the request produces three identical tasks. It is the most frequent bug in applications that talk to servers.

Delay the loading indicator by about 200 ms. If the response arrives in 80 ms, an indicator that appears and disappears produces an unpleasant flicker and contributes to CLS. Showing it only if the operation drags on is a small detail with a big effect on perceived quality.

let loadingTimer = setTimeout(() => store.update({ loading: true }), 200);
try {
  const data = await repo.listTasks();
  store.update({ tasks: data, loading: false, error: null });
} finally {
  clearTimeout(loadingTimer);
}

Skeletons versus spinners. A skeleton — gray blocks in the shape of the content that is coming — communicates better and, above all, reserves the space, avoiding the layout shift that the CLS budget from 11-01 penalizes. A centered spinner reserves nothing.

  1. Retries, cancellation and AbortController

Cancellation is the part most often forgotten, and it produces a very specific bug: the stale response that overwrites the good one.

The scenario, which happens whenever there is a search box:

t=0    ms  The user types "scr"    → request A
t=120  ms  The user types "scre"   → request B
t=400  ms  B's response arrives → the results for "scre" are rendered  ✅
t=650  ms  A's response arrives → the results for "scr" are rendered   ❌

The user sees results for a search that is no longer typed. And it is not a rare bug: it is what happens by default when requests take different amounts of time.

The solution with AbortController:

// src/application/use-cases.js
let searchController = null;

export async function search(store, repo, text) {
  searchController?.abort('superseded search');    // cancels the previous one
  searchController = new AbortController();

  try {
    const results = await repo.searchTasks(text, { signal: searchController.signal });
    store.update({ results, loading: false });
  } catch (error) {
    if (error.name === 'AbortError' || error.cause?.name === 'AbortError') return;  // expected
    store.update({ error: toUiError(error), loading: false });
  }
}

Three rules of cancellation:

  1. A cancellation is not an error. It is silently ignored. Showing "Error: request aborted" for something your own code triggered is baffling for the user.
  2. Cancel on unmount too. A view's destroy() must abort its in-flight requests. Otherwise the response arrives at a view that no longer exists, tries to touch a detached DOM and keeps its whole reference chain alive: it is one of the memory leaks you will hunt in 11-04.
  3. Cancellation and debounce go together. The debounce from 09-02 reduces the number of requests; cancellation ensures that, of the ones that do go out, only the last matters. You need both.

  1. Synchronization: who wins when two people edit at once

As soon as there is more than one device, the central problem of synchronization appears: two people edit the same task and you have to decide which one prevails.

The three strategies, with their real trade-offs:

Strategy How it works Advantages Drawbacks When to choose it
Last write wins The server accepts whatever arrives last Trivial to implement; never blocks Silently loses changes; depends on clocks Single-owner data; preferences
Version / updatedAt The client sends the version it read; the server rejects if it changed (409) Nothing is lost without warning; detectable and explainable Requires resolving the conflict in the interface The recommended one for Orbita
Field-level merge Changes to different fields are combined Many conflicts disappear on their own Complex; can produce incoherent states Documents with independent fields
CRDT / automatic merge Structures that converge without conflict Genuine real-time collaboration Very complex; the data format is constrained Document-style collaborative editing

For your project, the second. It offers the best ratio of cost to guarantee, and it is implemented like this:

// The client sends the version it had
await fetchJson(`${base}/tasks/${id}`, {
  method: 'PUT',
  headers: { 'If-Match': task.version },      // or in the body, if the API prefers
  body: JSON.stringify(task.toJSON())
});
// The server responds 409 Conflict if its version differs

And what to do with the 409, which is the part that determines the quality of the application:

Option Experience Recommendation
Overwrite without asking Somebody else's work is lost Never
Discard mine without asking My work is lost Never
Reload and warn "This task changed; the data has been reloaded" Acceptable if my change was trivial
Show both versions and let the user choose "You put 12 h; Marta put 8 h" with two buttons The correct one

Implementing the fourth costs a small screen and is exactly the kind of detail you will be able to talk about in an interview in 11-06: it shows you have thought about the awkward case.

About clocks. "Last write wins" compares timestamps, and client clocks are not reliable: they can be hours off. If you use timestamps to decide, always use the server's, never the client's. It is a detail that produces bugs that are impossible to reproduce.

  1. The pending-changes queue

Working offline — the PWA promise from 07-05 — requires that changes that could not be sent are not lost. The structure that solves it is a persistent queue of operations.

// src/data/queue.js
export class ChangeQueue {
  #key;

  enqueue(operation) {
    const entry = {
      id: crypto.randomUUID(),          // idempotency key (section 13)
      type: operation.type,             // 'create' | 'update' | 'delete'
      resource: operation.resource,     // 'task' | 'user'
      payload: operation.payload,
      createdAt: new Date().toISOString(),
      attempts: 0,
      lastError: null
    };
    this.#persist([...this.list(), entry]);
    return entry.id;
  }

  list() { /* reads from localStorage or IndexedDB */ }
  markAttempt(id, error) { /* attempts++, lastError */ }
  remove(id) { /* on confirmation */ }
  get pending() { return this.list().length; }
}

The lifecycle of a queued operation:

flowchart TD
    A["The user acts"] --> B["Applied locally<br/><i>optimistically</i>"]
    B --> C{"Is there a connection?"}
    C -->|Yes| D["Send to the server"]
    C -->|No| E["Enqueue"]
    D -->|2xx| F["Confirm:<br/>remove from the queue"]
    D -->|"4xx (not retryable)"| G["Roll back + warn<br/>+ drop from the queue"]
    D -->|"5xx / network"| E
    E --> H["Wait for the 'online' event<br/>or a scheduled retry"]
    H --> I["Flush the queue<br/>in order"]
    I --> D

    style E fill:#fef3c7,stroke:#d97706
    style G fill:#fee2e2,stroke:#b91c1c
    style F fill:#dcfce7,stroke:#16a34a

The five rules of a queue that works:

  1. Strict order. Operations are resent in the order they were queued. If "create task 7" and "update task 7" are sent the other way round, the second fails with a 404.
  2. Persistent, not in memory. If it lives in a variable, it is lost when the tab closes — exactly when it is most needed.
  3. Each entry with its idempotency key. Next section.
  4. An attempt limit. After 5 failures, the entry moves to "needs attention" and is shown to the user. A queue that endlessly retries an impossible operation drains the battery and never warns anyone.
  5. Visible. The user must be able to see how many changes are pending and why. A discreet indicator: "3 unsynced changes".

Triggering the flush, from three sources:

window.addEventListener('online', () => synchronizer.flush());
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') synchronizer.flush();
});
setInterval(() => { if (navigator.onLine) synchronizer.flush(); }, 60_000);

Careful with navigator.onLine: it tells you whether there is a network interface, not whether there is internet. On unauthenticated hotel wifi, it returns true and the requests fail anyway. It is useful as a hint not to try when there is clearly no network, but the truth comes from the fetch, not from the property.

  1. Idempotency and safe resending

An operation is idempotent if running it several times produces the same result as running it once. It is the property that makes resending safe, and without it the queue from the previous section is dangerous.

Operation Idempotent by nature? Risk on resend
GET /tasks Yes None
PUT /tasks/7 with the full object Yes None
DELETE /tasks/7 Yes (the second gives 404, which is fine) None
POST /tasks No Duplicated tasks
PATCH /tasks/7 { hours: +2 } (increment) No Hours added twice

The duplication scenario is this, and it happens more often than it seems:

1. The client sends POST /tasks
2. The server creates it correctly
3. The response is lost (network drops right then)
4. The client thinks it failed and requeues
5. When the network comes back, it resends → A SECOND IDENTICAL TASK

The solution: an idempotency key. The client generates a unique identifier per operation — not per retry — and sends it on every attempt:

await fetchJson(`${base}/tasks`, {
  method: 'POST',
  headers: { 'Idempotency-Key': entry.id },     // the same one across all 5 retries
  body: JSON.stringify(entry.payload)
});

The server stores the keys it has already processed and, if it sees a repeat, returns the original result instead of creating it again. It is the mechanism payment gateways use, for obvious reasons.

And if the server does not support idempotency keys? With json-server or another API you do not control, there are two partial mitigations:

  1. Have the client generate the id (a UUID) instead of leaving it to the server. Then the POST is effectively a PUT on a known identifier, and the second send overwrites instead of duplicating. It breaks R1 as originally written, so it is a decision to write down.
  2. Check before resending: a GET on a distinctive field to see whether it already exists. It is more fragile (there is a race condition between the check and the creation) but better than nothing.

And an API design rule worth knowing: prefer absolute operations over incremental ones. PATCH { hours: 14 } is idempotent; PATCH { hours: '+2' } is not. The first form eliminates an entire problem instead of managing it.

  1. Optimistic updates with rollback

An optimistic update applies the change to the interface before the server confirms it, assuming it is going to work. If it fails, it is rolled back.

Approach Perception Risk
Pessimistic: wait for the response 300–800 ms of waiting on every action None, but it feels slow
Optimistic: apply now, roll back on failure Instant You have to handle the rollback properly

The difference in feel is enormous, and that is why applications that feel fast do it this way. The pattern:

export async function markDone(store, repo, id) {
  const before = store.get().tasks;                       // 1 · save it for rollback
  const optimistic = before.map((t) => t.id === id ? t.withStatus('done') : t);

  store.update({ tasks: optimistic });                    // 2 · apply now
  announce('Task marked as done');

  try {
    const confirmed = await repo.updateTask(id, { status: 'done' });
    store.update({                                        // 3 · replace with the server's version
      tasks: store.get().tasks.map((t) => t.id === id ? confirmed : t)
    });
  } catch (error) {
    store.update({ tasks: before, error: toUiError(error) });   // 4 · roll back
    announce('The change could not be saved. It has been undone.');
    throw error;
  }
}

The four rules of optimistic updating:

  1. Save the complete previous state before touching anything. That is what lets you roll back exactly.
  2. Replace with whatever the server returns, do not keep your optimistic version. The server may have added fields — an updatedAt, a real id — or normalized something.
  3. The rollback must be visible and announced. A change that is silently undone makes the user believe they saved something they did not. It is worse than not being optimistic.
  4. Do not be optimistic about everything. The table:
Operation Optimistic? Why
Change status, mark, reorder Yes Reversible, low risk, very frequent
Edit a text field Yes Same
Create a task Carefully Yes, but with a temporary id marked as "pending"
Delete No, better to confirm Hard to roll back convincingly; alarming if it reappears
Pay, send, close permanently Never Irreversible actions are confirmed before being shown as done

The temporary id on creation deserves an explanation. If you create the optimistic task with id: -1 and then the server returns id: 42, anything done to the task in the meantime would point at a non-existent id. Two solutions: generate the UUID on the client (section 13), or mark the task visually as "saving" and block actions on it until it is confirmed. The first is cleaner.

  1. Real time without duplicating your own changes

If your project includes real time — the BoardChannel from 07-04, which extended EventTarget with reconnection, backoff and heartbeat — there is one very specific problem that always shows up:

The server sends your own change back to you, and you apply it twice.

The typical symptom: you create a task, it appears, and half a second later it appears again. Or the hours counter doubles. Three ways to solve it, from worst to best:

Solution How Assessment
Ignore messages for N ms after acting A time window Fragile: depends on latency
Client identifier Each message carries originId; it is ignored if it is your own Simple and reliable
Reconciliation by id and version Always applied, but as an idempotent replacement The most robust

The second is enough for almost everything:

// src/data/realtime.js
const MY_ID = crypto.randomUUID();      // one per tab, in memory

channel.addEventListener('task-updated', (event) => {
  const { task, originId } = event.detail;
  if (originId === MY_ID) return;                     // it is my own echo
  store.update({ tasks: mergeById(store.get().tasks, task) });
  announce(`${task.title} has been updated by somebody else`);
});

And mergeById implements the third as a bonus: it replaces by id if it exists, adds it if not, and ignores it if the incoming version is older than the one you have. With that condition, applying the same message twice is harmless, and the order of arrival stops mattering.

Three real-time rules that avoid trouble:

  1. The channel is destroyed on exit. The destroy() from 07-04 closes the socket and removes the listeners. Without it, navigating between screens opens connections that never close.
  2. Do not trust the order of arrival. Messages can arrive out of order. That is why the merge must be idempotent and version-based, not "apply whatever arrives".
  3. Announce other people's changes, not your own. Having the interface change on its own with no explanation is disorienting, especially for screen reader users. aria-live="polite" with "Marta has marked Screen-printing ink inventory as done".

  1. Security and privacy of what you store

This section is not optional and it is worth reading in full.

16.1 What must never be in the browser

Do not store Why What to do instead
API keys and secrets All client JavaScript is public: it is read with View Source The server holds the key and exposes an endpoint of its own
Passwords, even "encoded" ones Base64 is not encryption; it is decoded in a second They never leave the server; they are sent and forgotten
Long-lived tokens in localStorage Any XSS steals them (section 16.2) An HttpOnly + Secure + SameSite cookie, or a short-lived token in memory
Sensitive personal data Health, beliefs, biometrics… a special GDPR category Do not process it; and if the product requires it, only with legal advice
Third-party data with no legal basis It is not yours Fictional data in development, always

The token case deserves detail because it is the most frequent mistake. A session token in localStorage is accessible from any JavaScript on the page. If an attacker manages to run code — a compromised dependency, an XSS — they walk off with the whole session. An HttpOnly cookie is not accessible from JavaScript, so that vector disappears. It is not a theoretical difference: it is the difference between an annoying XSS and account theft.

16.2 XSS: why it matters more once there is an API

Lesson 06-02 established the rule: textContent for data, innerHTML only for your own literals. With data coming from an API the risk multiplies, because somebody else wrote the content.

// ❌ If the title comes from an API and contains <img src=x onerror="...">, it runs
row.innerHTML = `<h3>${task.title}</h3>`;

// ✅ Text is text
row.querySelector('h3').textContent = task.title;

The complete rule:

Situation Correct usage
Text from data textContent
Attribute from data setAttribute with a validated value
URL from data Validate the scheme: only http: and https:
Rich HTML from users Sanitize with a maintained library, never by hand
Your own fixed structure innerHTML with literals, or <template>

URL validation is the one that gets forgotten: javascript:alert(1) in a link field runs when clicked. Always check the scheme before assigning an href.

And in 11-05 you will add the second layer: a Content-Security-Policy that prevents inline scripts from running even if one slips through.

16.3 Personal data and GDPR

The moment your application stores names, email addresses, photos or any data that identifies a person — even indirectly — you are processing personal data, and in the European Union that is regulated by the GDPR.

What I can tell you with confidence, because they are principles of the regulation:

Principle What it means in your project
Minimization Store only what is necessary. Do you need the date of birth? Almost certainly not
Purpose limitation Data collected for one thing is not used for another
Storage limitation Define how long it is kept and delete it afterwards
Integrity and confidentiality Encryption in transit (HTTPS, 11-05) and access control
Transparency The person must know what you store, why and for how long
Rights Access, rectification, erasure and portability of their data

And what I cannot give you: legal advice.

Explicit warning. A real product that processes real people's data requires a legal and regulatory-compliance review: legal basis for the processing, information for the data subject, a record of processing activities, processors (any external service you use), international transfers, and in some cases an impact assessment. None of this is solved with code and none of it is the subject of a JavaScript course. While you are learning, use fictional data — like Taller Nómada's — and do not publish a product with real data without consulting the appropriate professionals.

What you can do right now, and it is good engineering as well as good legal practice:

  • Document in docs/data-model.md which fields are personal and what for.
  • Implement exporting all of one person's data (it doubles as a backup).
  • Implement genuine deletion, not an active: false in disguise. Careful: real deletion clashes with R14's immutable history. The usual solution is to anonymize the history entries — replace the name with "Deleted user" — while keeping the log's integrity. It is a decision that deserves its ADR.
  • Do not log personal data in the error system, as already stated in 11-02.

  1. Synchronization failures and how to handle them

This table is the operational summary of the whole lesson. Keep it at hand while you implement: every row is a failure that is going to happen.

# Failure Symptom Cause Treatment
1 Offline when saving The action never reaches the server Network down Enqueue + apply locally + a pending indicator
2 Server down (5xx) Error after a wait Temporary failure Retry with backoff; after N, enqueue
3 Hanging request Endless spinner No timeout AbortController with a timeout (section 8)
4 Stale response Data from an earlier search Race between requests Cancel the previous one before firing
5 Edit conflict (409) The change is rejected Somebody else edited first Show both versions and let the user choose
6 Duplicate on resend Two identical tasks Non-idempotent POST Idempotency key or client-generated id
7 Real-time echo Your own change appears twice The server broadcasts to everybody originId + merge by id and version
8 Quota full QuotaExceededError The history grew Prune, warn, and if it still does not fit, an actionable message
9 Data from a future version It cannot be opened Another device updated first Reject with a clear message; do not guess
10 Corrupting migration Odd data after an update A buggy migration Prior backup + validation against the domain
11 Corrupt JSON SyntaxError at startup Interrupted write try/catch when parsing + start from the backup
12 Skewed client clock Wrong change ordering Wrong local time Always use the server's timestamp
13 Stuck queue Nothing ever syncs An impossible operation blocks the order Attempt limit + a "needs attention" entry
14 Two tabs of the same user They overwrite each other's local data Both write to the same key A storage event or BroadcastChannel to coordinate

Case 14 is the one that most surprises people the first time they meet it. Two tabs open with the same application write to the same localStorage without knowing it. The storage event notifies the other tabs when one writes:

window.addEventListener('storage', (e) => {
  if (e.key !== KEY) return;
  store.update({ ...readDocument(), noticeFromOtherTab: true });
});

With five lines, the two tabs stay consistent. Without them, whichever saves last overwrites the other one's work.

Common Mistakes and Tips

Not versioning the stored format. It is the error that destroys the most data. Without version in the document, the day you add a mandatory field you will have two indistinguishable formats coexisting, and no clean way to tell which is which. Putting version: 1 in from day one costs one line.

Migrating without a backup. A buggy migration destroys data irreversibly. Saving the original under another key before touching it costs one line and turns a disaster into an incident.

Migrations not tested with real data. Testing the migration with an object you hand-wrote for the test proves little: real data has unexpected fields, nulls where you did not expect them and structures from intermediate versions. Save real documents as test fixtures.

Assuming fetch throws on an HTTP error. It does not. Without if (!response.ok), a 500 is processed as a success and the failure shows up three layers higher with an incomprehensible message.

Not setting a timeout on requests. fetch waits indefinitely. On a bad network, the loading indicator spins forever and the user has no way out.

Retrying what should not be retried. A 400 will give 400 all three times. Retrying it only triples the wait before the same error. Only the retryable is retried: network, 408, 429 and 5xx.

Not canceling stale requests. It produces the most baffling bug of all: results from an earlier search overwriting the good ones, intermittently and dependent on latency. Practically impossible to reproduce on purpose if you do not know it exists.

POST in the queue without idempotency. Resending a creation after a network failure duplicates the record. It is the bug that produces "I have the same task three times" and that the user can never explain.

Rolling back silently. If an optimistic update fails and you undo it without saying so, the user believes they saved something that was not saved. It is worse than never being optimistic.

Storing tokens in localStorage. Any XSS walks off with the whole session. An HttpOnly cookie or a short-lived token in memory.

Putting personal data in the error log. It is a legal problem, not just a style one, and it gets worse as soon as that log is sent to an external service (11-05). Log identifiers and field names, never values.

Tip · Measure the size of your data before choosing storage. Thirty seconds of JSON.stringify with realistic data prevents both the naivety of localStorage with 20 MB and the over-engineering of IndexedDB with 200 kB.

Tip · Test with the network throttled and disconnected. The DevTools Network panel has an Offline mode and slow profiles. Half the failures in this lesson only show up there. Make it part of your increment-closing routine.

Tip · Implement export and import early. A button that downloads all the data as JSON serves as a manual backup, a debugging tool, a way to move data between devices without a server, and compliance with the right to portability. Four benefits for an afternoon of work.

Tip · Leave a hidden diagnostics panel. A screen with the format version, the space used, the queue entries and the last logged errors. It will save you hours when something fails on a device that is not yours.

Exercises

These exercises are milestone H4 of your project: persistence with migrations, the API layer with its states, and the offline queue.

Exercise 1 — Persistence with tested migrations.

  1. Implement LocalRepository satisfying the full contract, with the wrapped document (version, savedAt, app, data).
  2. Run the contract test battery against it and against MemoryRepository; both must pass exactly the same tests.
  3. Implement migrations.js with at least three real migrations from your project (not invented ones: changes you have genuinely made or are going to make to the model).
  4. Save real test documents from every old version in test/data/fixtures/, including an empty one and one with an unexpected value.
  5. Write the six migration tests from section 6.1: it does not lose data, it maps correctly, it tolerates unknown data, it produces documents valid for the domain, it is idempotent, and it rejects future versions.
  6. Implement the prior backup, the handling of QuotaExceededError with history pruning and a warning, and the detection of unavailable storage with degradation to memory.
  7. Implement export and import of all the data as JSON, with validation on import.

Exercise 2 — The API layer with its states.

Set up a local test server (json-server or equivalent) and:

  1. Implement http.js with ApiError (with status, code, retryable), fetchJson with a timeout and withRetries with exponential backoff and jitter.
  2. Implement ApiRepository satisfying the contract and passing the same test battery as the other two.
  3. Implement the five states in the interface: idle, loading (with a skeleton, aria-busy and a 200 ms delay), success, error (with a retry action) and canceled.
  4. Implement cancellation of the search with AbortController, combined with debounce, and prove with a test that a stale response does not overwrite the good one.
  5. Implement handling of the 409 with the resolution screen showing both versions.
  6. Write tests with a fake fetch (08-04) for: 200, 400 with a useful body, 500 with a successful retry on the second attempt, timeout, and cancellation.

Exercise 3 — The offline queue and optimistic updating.

  1. Implement a persistent ChangeQueue, with an idempotency id, an attempt counter, strict ordering and a retry limit.
  2. Implement SyncedRepository, which satisfies the same contract by combining local + API + queue.
  3. Implement flushing triggered by online, visibilitychange and a timer, with the navigator.onLine caveat.
  4. Implement optimistic updating with rollback in at least three operations, with an announcement when it rolls back, and respect the table of what should not be optimistic.
  5. Implement an accessible "N unsynced changes" indicator and a queue view listing the entries that need attention.
  6. Implement cross-tab coordination with the storage event or BroadcastChannel.
  7. Write tests with fake timers for: enqueuing with no network, flushing when it comes back, order preserved, no duplicates on resend, and rollback on failure.
  8. Demonstrate it by hand: with DevTools in Offline mode, make five changes, reconnect and check that all five arrive in order and without duplicates. Record a GIF: it will come in handy for the 11-06 demo.

Solutions

Acceptance criteria for exercise 1 — Persistence

# Criterion How it is checked
1 The data survives a reload Create a task, F5, it is still there
2 The document carries version Inspect localStorage in DevTools
3 A v1 document opens without losing anything Paste a real v1 and check the task count
4 A backup is made before migrating orbita:board:backup:v1 exists after migrating
5 Migrating is idempotent migrate(migrate(x)) equals migrate(x)
6 The migrated data is valid for the domain Task.fromJSON throws on no element
7 A future version is rejected with a message Set version: 99 and see the warning
8 Corrupt JSON does not prevent startup Write {{{ into the key; the application starts and warns
9 A full quota prunes and warns Fill localStorage on purpose and observe
10 With no storage, it works in memory and warns Simulate the setItem failure
11 Contract: memory and local pass the same The same test function, two calls
12 Export produces re-importable JSON Export, delete everything, import, compare

Rubric for exercise 1 (21 points)

Dimension 0 1 2 3
Versioning No version Field present Complete wrapped document Plus app and its check
Migrations None One, untested ≥ 3 tested With real data and validation against the domain
Robustness No try/catch Generic catch Quota, corruption and unavailability handled Plus degradation and actionable messages
Backup None Manual Automatic before migrating Plus recoverable from the interface
Contract Only one implementation Two with no shared tests Shared tests Identical and green for all three
Export/import No Exports Exports and imports With validation and per-row error messages
Tests < 5 5–9 ≥ 10 including the 6 migration ones Plus real edge cases

Threshold: 15/21, with a mandatory 3 in "Migrations". It is the part that destroys data when it is wrong.

Acceptance criteria for exercise 2 — API

# Criterion How it is checked
1 A 500 is treated as an error Simulate it and check it is not processed as a success
2 A 400 shows the server's message The error body reaches the interface
3 A hanging request is cut off Delay 30 s; at 8 s there is a timeout error
4 A 5xx is retried, a 400 is not Count the calls in the fake fetch
5 The backoff is exponential with noise Check the timings with fake timers
6 The indicator takes 200 ms to appear An 80 ms response: no flicker
7 The trigger is disabled while loading A fast double click produces one request
8 The stale response does not overwrite A test with two out-of-order responses
9 Canceling shows no error No alert when aborting
10 The 409 offers a version choice A screen with both values and two actions
11 Contract: the API passes the same tests The shared battery green
12 No secrets in the client Search for keys in dist/ after building: zero results

Acceptance criteria for exercise 3 — Queue and optimism

# Criterion How it is checked
1 With no network, the action applies and enqueues Offline mode + inspect the queue
2 The queue survives closing the tab Close and reopen; the entries are still there
3 It flushes on its own when the network returns Back to Online without reloading
4 Order is preserved Create and then update: they arrive in that order
5 There are no duplicates Cut the network after sending and before receiving; on resend, a single task
6 After N attempts it is marked "needs attention" Force a permanent 400
7 The rollback is visible and announced Force the failure; the change is undone with a warning
8 Deleting is not optimistic It is confirmed first
9 The pending indicator is accessible Text, not just an icon; announced on change
10 Two tabs stay consistent Change in one, see the effect in the other
11 The real-time echo does not duplicate If you implement it: create and observe a single card
12 The manual demonstration works The GIF of 5 offline changes

Overall rubric for milestone H4 (24 points)

Dimension Weight What is assessed
Persistence and migrations 6 Versioning, migration chain, backup, tests with real data
Network robustness 5 Typed errors, timeout, selective retries, cancellation
Interface states 4 The five states, no flicker, no double submit, accessible
Queue and offline 5 Persistence, ordering, idempotency, attempt limit, visibility
Conflicts 2 Detection and resolution with user involvement
Security and privacy 2 No secrets, no innerHTML with data, log with no personal data

Threshold: 17/24. A 0 in "Security and privacy" invalidates the milestone regardless of the rest: a product that leaks an API key or that executes whatever HTML it is sent is not finished, however well everything else works.

Self-assessment for milestone H4:

Question Yes / No
Could I switch from localStorage to IndexedDB by touching only one file?
Do I know what happens if a user opens data from an old version? And from a future one?
Have I tested my application with the network disconnected?
Is there any key, token or secret in my client code?
Do I use innerHTML with any data I did not write myself?
Does my error log contain any personal data?
Can I export all my data and import it back?

Questions 4, 5 and 6 are the ones that have to be answered "no". If any is "yes", fix it before moving on to the next lesson: in 11-05 that application will be published on the internet.

Conclusion

You have turned an application that lost everything on reload into a product whose data survives, is shared and can be recovered.

You know how to choose storage with a decision tree that starts from an honest question — "how big is it in the worst case?" — whose correct answer at the start is "I don't know" and whose way out is not guessing but measuring with realistic data: thirty seconds of JSON.stringify that prevent both naivety and over-engineering. You know the five limits of localStorage — quota, synchrony, text only, all or nothing, no queries — you know the dangerous one is the quota because it fails on somebody else's device, and you know how to handle it by pruning what is expendable, warning about the pruning and giving an actionable message when it no longer fits. And you know IndexedDB at a practical level: transactions that close themselves if you slip in an unrelated await, a schema that only changes in onupgradeneeded, onblocked for the two-tab case, and a promise wrapper you write once.

You have what separates a serious project from a toy: the stored format versioned in an envelope with version, savedAt and app, and a chain of numbered migrations that are pure functions, are applied in sequence, reject future versions with a clear message, use conservative defaults and prefer losing an assignment over preventing the application from opening. With the six tests that back them up — it does not lose data, it maps correctly, it tolerates the unknown, it produces documents valid for the domain, it is idempotent and it rejects the future — and with real documents saved as test fixtures, because real data has fields you would never have hand-written. And with the prior backup, which costs one line and turns an irreversible disaster into an investigable incident.

You have cashed in the investment of the repository contract: the same test battery run against memory, local and API proves they are interchangeable, and switching storage is a one-line change in main.js. It is the dependency injection from 08-04 seen from the other side: what made testing with doubles possible makes changing storage technology possible.

You know how to talk to an API with the seven points of fetchJson: check response.ok because fetch does not throw on a 500, set a timeout because it has none, propagate the external signal, read the error body because that is where the useful information is, wrap the json() because a 500 can return HTML, handle the 204, and unify everything into ApiError. With retries only for the retryable, exponential backoff and jitter. And you know an asynchronous operation has five states and not two, that the indicator must be delayed 200 ms so it does not flicker, that the trigger is disabled while loading, and that a skeleton reserves the space a spinner does not.

You know how to cancel, which is what prevents the most baffling bug of all: the stale response that overwrites the good one. With the three rules — a cancellation is not an error, you also cancel on unmount, and debounce and cancellation go together because they solve different things.

You know how to decide who wins when two people edit: the table of four strategies, the recommendation of versioning with 409, and above all what to do with that 409 — neither overwrite nor discard silently, but show both versions and let the user choose, which is the detail that shows you have thought about the awkward case. With the warning about client clocks, which are never reliable.

You have the pending-changes queue with its five rules — strict ordering, persistent, with an idempotency key, with an attempt limit and visible — its three flush triggers, and the caveat about navigator.onLine, which tells you whether there is a network interface and not whether there is internet. You know what idempotency is, why POST is the only dangerous verb, how the idempotency key prevents the duplicate that produces "I have the same task three times", and why it is worth preferring absolute operations over incremental ones.

You know how to make the application feel instant with optimistic updates: save the previous state, apply now, replace with whatever the server returns and roll back with a warning if it fails — because a silent rollback is worse than never being optimistic. And you know what not to be optimistic about: deleting, paying, and anything irreversible. And you know how to integrate real time without duplicating your own changes, with originId and a merge by id and version that makes applying the same message twice harmless.

And you know what cannot be in the browser: keys, passwords, long-lived tokens in localStorage that any XSS walks off with, and personal data with no legal basis. You know that textContent for data and innerHTML only for your own matters far more when the content comes from an API, that URLs have to be validated by scheme, and that the GDPR principles — minimization, purpose, retention, transparency, rights — translate into concrete modeling decisions. With the warning stated plainly: a real product with real people's data requires a legal and compliance review, and that is not solved with code.

You close with the table of the fourteen synchronization failures that are going to happen, including the two tabs of the same user overwriting each other, which is solved with five lines and the storage event.

Milestone H4 is closed: persistence with migrations, an API layer with its states and an offline queue. Your project now does everything it promises. The remaining question is the uncomfortable one: how do you know it still does tomorrow? Because there are now migrations that can corrupt data, races that only appear on a bad network and views that can retain memory when you navigate — three failures you cannot see by looking at the screen. Turning quality into something automatic and verifiable, and debugging those three specific failures methodically, is Testing and Debugging the Project.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved