Back

How to Persist Form State in the Browser

How to Persist Form State in the Browser

You spend ten minutes filling out a multi-step job application. You accidentally hit the back button. Everything is gone.

This is one of the most frustrating UX failures in web development, and it’s entirely preventable. Here’s how to persist form state in the browser using the right storage mechanism for your situation.

Key Takeaways

  • Single-page applications often lose form data on navigation because the DOM may be re-rendered from scratch instead of restored from cache.
  • Choose your storage based on lifespan needs: localStorage for long-term drafts, sessionStorage for single-tab sessions, and IndexedDB for large or structured data.
  • The core autosave pattern is simple: debounce input events, save to storage, restore on mount, and clear on successful submit.
  • Never store passwords, tokens, or payment details in Web Storage — they are vulnerable to XSS attacks.
  • Always wrap storage writes in try/catch to handle QuotaExceededError and partitioned storage gracefully.

Why Form Data Disappears in Modern Apps

Traditional server-rendered pages get a small reprieve here. Browsers often restore form values on back navigation because the page itself is cached. Single-page applications do not always get that benefit. When your JavaScript re-renders a form from scratch, the browser may have no cached DOM to restore, so the fields come back empty.

The fix is to save form state to browser storage yourself, and restore it when the form mounts.

Choosing the Right Storage Mechanism

Not every persistence problem needs the same solution. Here’s a practical comparison:

MethodPersists UntilTab IsolatedSize LimitBest For
localStorageManually clearedNo~5–10 MBLong-term drafts
sessionStorageTab closesYes~5–10 MBSingle-session forms
IndexedDBManually clearedNoBrowser-dependentLarge or structured data
History API stateNavigation entryYesSmall objectsSPA back/forward nav

localStorage forms work well for drafts you want to survive browser restarts, like a blog post editor or a long registration form. sessionStorage forms are better when you only need data to survive a refresh within the same tab, not across tabs. IndexedDB form drafts make sense when you’re storing rich content, file metadata, or complex nested objects that would be unwieldy as JSON strings.

Both localStorage and sessionStorage are synchronous and string-based, meaning every read and write blocks the main thread and requires JSON.stringify/JSON.parse. IndexedDB is asynchronous and handles structured data natively, making it a better fit for anything beyond simple key-value pairs. All three are well supported in modern browsers.

Implementing Autosave with localStorage

The core pattern is straightforward: save on input, restore on load, clear on successful submit.

const DRAFT_KEY = 'contact_form_draft';
const form = document.querySelector('#contact-form');

// Autosave with debounce
let saveTimer;
form.addEventListener('input', (e) => {
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    const formData = Object.fromEntries(new FormData(e.currentTarget));
    try {
      localStorage.setItem(DRAFT_KEY, JSON.stringify(formData));
    } catch (err) {
      if (err.name === 'QuotaExceededError') {
        console.warn('Storage full, draft not saved');
      }
    }
  }, 500);
});

// Restore on load
window.addEventListener('DOMContentLoaded', () => {
  const saved = localStorage.getItem(DRAFT_KEY);
  if (!saved) return;
  try {
    const draft = JSON.parse(saved);
    Object.entries(draft).forEach(([name, value]) => {
      const field = form.querySelector(`[name="${name}"]`);
      if (field) field.value = value;
    });
  } catch {
    localStorage.removeItem(DRAFT_KEY);
  }
});

// Clear after successful submit
form.addEventListener('submit', () => {
  localStorage.removeItem(DRAFT_KEY);
});

Debouncing the save call (500ms here) prevents hammering storage on every keystroke. Always wrap setItem in a try/catch — browsers can and do throw QuotaExceededError, especially in low-storage environments or when third-party storage is partitioned. Wrapping JSON.parse in a try/catch is also wise, since a corrupted draft would otherwise throw and break the restore step.

What Not to Store

Never persist passwords, payment card numbers, authentication tokens, or any sensitive personal data in localStorage or sessionStorage. These APIs are accessible to any JavaScript running on the page, making them vulnerable to XSS attacks. If your form collects sensitive fields, exclude them from your draft logic entirely.

Also worth knowing: in embedded or third-party contexts, browsers increasingly partition or restrict access to Web Storage. Don’t assume storage is always available — check for it and fail gracefully.

Clearing Drafts and Edge Cases

Always delete the draft after a successful form submission. Stale drafts that reappear unexpectedly confuse users. If your form structure changes over time, consider versioning your storage key (e.g., contact_form_draft_v2) so old drafts don’t cause silent restore errors.

Conclusion

Browser form persistence doesn’t require a library or a backend. A small amount of deliberate JavaScript — debounced saves, safe restores, and cleanup on submit — is enough to prevent data loss and make your forms feel noticeably more reliable.

FAQs

Use localStorage when you want the draft to survive browser restarts, such as long forms, blog editors, or multi-step applications users may return to days later. Use sessionStorage when the draft only needs to survive an accidental refresh within the same tab and should disappear when the tab closes. Both share the same API, so switching between them is a one-line change.

File inputs cannot be restored programmatically for security reasons. The browser will not let you set the value of a file input. If users upload files, store the file metadata or upload immediately to the server and persist the resulting reference ID. For larger files held client-side, use IndexedDB to store the File or Blob object directly until submission.

For typical forms, no. localStorage writes are fast for small payloads, and debouncing input events to around 500 milliseconds keeps writes infrequent. Problems appear when drafts grow large or saves run on every keystroke without debouncing, since each write blocks the main thread. For large or structured data, switch to IndexedDB, which is asynchronous and non-blocking.

Listen for the storage event on the window object. It fires in other tabs whenever localStorage changes in one tab, giving you the key and new value. You can then update the form fields in the listening tabs accordingly. Note that the storage event does not fire in the tab that made the change, only in other tabs viewing the same origin.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay