DEV Community

Bhupendra Kumar Varshney
Bhupendra Kumar Varshney

Posted on

How to Save User Progress in localStorage Without Accounts

If your app only needs to remember one person's progress on one device, you may not need accounts, a database or a sync server at all. You can save progress in localStorage, migrate old saves when your data shape changes, and give people a backup file they can carry to another device.

This post walks through that pattern in plain JavaScript, with the trade-offs you should be honest with your users about.

When this approach fits

It works well when:

  • the data belongs to one person and is small (settings, scores, which lessons are done);
  • you would rather not collect email addresses or passwords;
  • losing the data would be annoying but not harmful, and users can keep a backup.

It does not fit when people expect their progress to follow them automatically between devices, or when the data is large or shared. localStorage is synchronous, string-only and limited to a few megabytes per origin; MDN's page on storage quotas explains the limits and when browsers clear data.

1. One versioned key, and a check that storage works

Keep the whole state under one key with a version in its name, and check that writing actually works. Storage can be disabled, full or blocked, and setItem throws in those cases.

const KEY = 'myapp.v1';
const SCHEMA = 1;

function storageWorks() {
  try {
    const probe = KEY + '.probe';
    window.localStorage.setItem(probe, '1');
    window.localStorage.removeItem(probe);
    return true;
  } catch (err) {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

If this returns false, the app can still run for the session; just tell the user that progress will not be kept.

2. Defaults and a migration step

Your data shape will change. Instead of breaking old saves, merge whatever was stored over a fresh default state, so new fields get sensible values and old fields survive.

function defaultState() {
  return {
    schema: SCHEMA,
    createdAt: Date.now(),
    settings: { dailyGoal: 50, theme: 'system' },
    lessons: {},
  };
}

function migrate(raw) {
  const base = defaultState();
  if (!raw || typeof raw !== 'object') return base;
  return {
    ...base,
    ...raw,
    settings: { ...base.settings, ...(raw.settings || {}) },
    lessons: raw.lessons && typeof raw.lessons === 'object' ? raw.lessons : {},
    schema: SCHEMA,
  };
}
Enter fullscreen mode Exit fullscreen mode

Merging nested objects separately matters: a plain spread would replace the whole settings object and drop any setting added after the save was written.

3. Load without destroying unreadable data

If the stored text is not valid JSON (a bug, a half-finished write, a manual edit), do not silently overwrite it. Keep a copy aside so you can recover it later, then start fresh.

function load() {
  if (!storageWorks()) return defaultState();
  const text = window.localStorage.getItem(KEY);
  if (!text) return defaultState();
  try {
    return migrate(JSON.parse(text));
  } catch (err) {
    try {
      window.localStorage.setItem(KEY + '.corrupt.' + Date.now(), text);
    } catch (ignored) {
      // storage is full: nothing more we can do safely
    }
    return defaultState();
  }
}

function save(state) {
  try {
    window.localStorage.setItem(KEY, JSON.stringify(state));
    return true;
  } catch (err) {
    return false; // quota exceeded or storage disabled: tell the user
  }
}
Enter fullscreen mode Exit fullscreen mode

save returns a boolean rather than throwing, so the interface can show a quiet warning instead of crashing in the middle of a lesson.

4. Export a backup file

A backup is the user's own copy of their data. Wrap the state with an app marker and a timestamp so an import can recognise it.

function makeBackup(state) {
  return JSON.stringify({ app: 'myapp', exportedAt: new Date().toISOString(), state }, null, 2);
}

function exportProgress(state) {
  const blob = new Blob([makeBackup(state)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'myapp-progress-' + new Date().toISOString().slice(0, 10) + '.json';
  a.click();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}
Enter fullscreen mode Exit fullscreen mode

5. Import it, carefully

Treat an imported file as untrusted input: limit its size, check that it is really your backup, run it through the same migration as a normal load, and ask before replacing what is already there.

function parseBackup(text) {
  let data;
  try {
    data = JSON.parse(text);
  } catch (err) {
    throw new Error('That file is not valid JSON.');
  }
  if (!data || data.app !== 'myapp' || typeof data.state !== 'object') {
    throw new Error('That file is not a progress backup from this app.');
  }
  return migrate(data.state);
}

async function importProgress(file, current) {
  if (file.size > 2000000) throw new Error('That file is too large to be a progress backup.');
  const next = parseBackup(await file.text());
  if (!window.confirm('Replace your current progress with this backup?')) return current;
  save(next);
  return next;
}
Enter fullscreen mode Exit fullscreen mode

Because parseBackup calls migrate, a backup exported by an older version of your app still imports cleanly into a newer one.

Trade-offs to state plainly

  • One browser, one device. Without sync, progress does not follow the user. Say so where they will see it, and point them to the export button.
  • Clearing site data deletes it. So does a private window when it closes. Browsers can also evict storage; Safari, for example, deletes a site's script-writable storage after seven days of Safari use without the user interacting with that site, as described on the WebKit blog.
  • No server copy means no recovery. If a user loses the device and has no backup, the progress is gone. That is the price of not collecting their data.

Where this came from

I build LinguaFlow, a French course for English speakers that runs in the browser without an account, and its progress storage follows this pattern: one versioned localStorage key, a migration on load, unreadable data set aside, and backup files that people export and import to move between devices. You can see it at linguaflow.aerosapiens.in. The code above is a simplified version written for this post.

This post was written with AI assistance and reviewed by me before publication.


Bhupendra Varshney is the founder of AeroSapiens, a software company in Noida, India.

Top comments (0)