How TypeWords Persists User Input to IndexedDB: Complete Data Flow Explained

TypeWords captures every keystroke in a Vue component, builds a serializable result object when the test finishes, and persists it to IndexedDB using the lightweight idb-keyval library.

The open-source typing test application TypeWords implements a clean, predictable data flow from user input to browser storage. According to the source code in zyronon/TypeWords, the architecture relies on Vue's reactivity system for state management, plain JavaScript objects for serialization, and a minimal IndexedDB wrapper (public/libs/idb-keyval.js) for persistence. This article traces every step of that pipeline with specific file references and runnable code examples.


Capturing Keystrokes in the Main Typing Interface

The data journey begins in app/pages/index.vue, which hosts the primary typing interface. A standard textarea element binds user input to reactive state through Vue's v-model directive:

<textarea
  v-model="typed"
  @input="onInput"
  @keydown.enter.prevent="finishTest"
/>
  • typed — A reactive string that accumulates every character the user types
  • onInput — Event handler that updates internal counters (character count, accuracy percentage) in real time
  • finishTest — Triggered when the user presses Enter, signaling test completion and initiating persistence

The component maintains derived statistics throughout the session, computing values like words per minute (WPM) from the elapsed time and character count.


Building the Serializable Result Payload

When finishTest executes, the component assembles a plain JavaScript object containing all session data. IndexedDB requires structured clone algorithm-compatible values, so the payload excludes non-serializable types like functions or DOM references:

const payload = {
  wpm: computedWPM,
  accuracy: computedAccuracy,
  date: new Date().toISOString(),
  // Optional: complete typed text for replay functionality
  text: typed,
};

Key design decisions in this payload structure:

  • date — ISO 8601 string provides deterministic, sortable keys for later retrieval
  • All primitive values — Ensures reliable serialization across IndexedDB transactions
  • Optional text field — Supports future replay or analysis features without breaking existing consumers

Persisting to IndexedDB via idb-keyval

The actual storage operation delegates to public/libs/idb-keyval.js, a minimal wrapper that exposes four methods: get, set, del, and clear. The set method accepts a string key and any serializable value, returning a Promise that resolves when the transaction commits:

import { set } from '@/public/libs/idb-keyval';

const key = `test-${payload.date}`;
await set(key, payload);

Why idb-keyval instead of raw IndexedDB? The source code prioritizes developer ergonomics—the library handles database versioning, object store creation, and transaction boilerplate in approximately 100 lines of code. This eliminates the verbose openDB → transaction → objectStore → put ceremony of standard IndexedDB.

The key naming convention (test-<ISO-date>) enables chronological enumeration without maintaining a separate index. Each test result receives a unique, human-readable identifier derived from its timestamp.


Retrieving and Rehydrating Stored Sessions

Data flows back through app/components/dialog/MigrateDialog.vue, which provides a user interface for browsing and migrating historical test results. The component imports the same get helper to read individual entries:

import { get } from '@/public/libs/idb-keyval';

for (const key of storedKeys) {
  const data = await get(key);
  if (data) {
    this.savedTests.push(data);
  }
}

The dialog additionally implements bidirectional migration logic that copies IndexedDB contents into the application's Pinia store when available:

if (payload.indexedDB[key] !== undefined) {
  await set(key, payload.indexedDB[key]);
}

This dual-read pattern—direct IndexedDB access for display, store synchronization for state management—ensures the UI remains responsive while supporting offline-first persistence.


Practical Implementation Examples

Saving a Test Result Programmatically

import { set } from '@/public/libs/idb-keyval';

async function saveTestResult(result: {
  wpm: number;
  accuracy: number;
  date: string;
  text: string;
}): Promise<void> {
  const key = `test-${result.date}`;
  await set(key, result);
  console.log(`Persisted test result under key: ${key}`);
}

Loading All Historical Results

import { get } from '@/public/libs/idb-keyval';

// Note: idb-keyval 6.x+ includes a keys() method for enumeration
import { keys, get } from '@/public/libs/idb-keyval';

async function loadAllTests(): Promise<Array<Record<string, unknown>>> {
  const allKeys = await keys();
  const tests: Array<Record<string, unknown>> = [];
  
  for (const key of allKeys) {
    const data = await get(key);
    if (data && key.startsWith('test-')) {
      tests.push(data as Record<string, unknown>);
    }
  }
  
  return tests.sort((a, b) => 
    new Date(b.date as string).getTime() - new Date(a.date as string).getTime()
  );
}

Architecture Strengths and Trade-offs

Aspect Implementation Rationale
Storage abstraction idb-keyval wrapper Reduces IndexedDB boilerplate by ~80%
Key generation ISO date prefixes Human-readable, chronologically sortable
Value serialization Plain objects only Guaranteed structured clone compliance
Read patterns Direct IndexedDB + Pinia sync Decouples persistence from UI state

The TypeWords approach prioritizes simplicity over normalization: each test result is an independent record with no foreign key relationships or complex indexing. This suits the application's read-heavy, write-once usage pattern where users generate one result per typing session and rarely modify historical data.


Summary

  • Input capture happens in app/pages/index.vue through Vue v-model binding to a reactive string
  • Result assembly creates a serializable payload with WPM, accuracy, timestamp, and optional full text
  • Persistence uses set() from public/libs/idb-keyval.js with deterministic date-based keys
  • Retrieval occurs in app/components/dialog/MigrateDialog.vue via get() calls, supporting both UI display and store migration
  • Design philosophy favors minimal abstraction and direct IndexedDB interaction over complex ORM patterns

Frequently Asked Questions

Does TypeWords use a database library or raw IndexedDB API?

TypeWords uses idb-keyval (public/libs/idb-keyval.js), a ~100-line wrapper that simplifies IndexedDB operations to get, set, del, and clear methods. The library handles connection management, object store creation, and transaction lifecycle automatically while preserving full IndexedDB reliability.

What format does TypeWords use for IndexedDB keys?

Keys follow the pattern test-<ISO-8601-date>, for example test-2024-01-15T09:30:00.000Z. This provides chronological sorting without additional indexes and makes debugging straightforward when inspecting stored data.

Can the stored typing data be exported or migrated?

Yes—the MigrateDialog.vue component implements bidirectional migration. It reads all IndexedDB entries and can copy them into a Pinia store, or conversely restore from an external payload back into IndexedDB using the same set() method.

Is the typed text content stored or just statistics?

Both. The payload includes wpm, accuracy, and date as required fields, plus an optional text field containing the complete string the user typed. This supports potential replay features while keeping minimal storage footprint when disabled.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →