DEV Community

Im Woojin
Im Woojin

Posted on

Migrating from LocalStorage to Native FS in Tauri: Repo-rter v0.4.1 Technical Deep Dive

A few days ago, I introduced Repo-rterโ€”a local-first desktop app built with Next.js and Tauri v2 to bypass GitHubโ€™s annoying 14-day traffic history limit.

While the launch was successful, we quickly hit some critical architectural bottlenecks in production. In our latest v0.4.1 release, we completely refactored the data layer.

Here is a technical breakdown of how we migrated from WebView localStorage to a native file-system cache, bypassed API rate limits, and implemented automatic data purging.


๐Ÿ›‘ The Problem: Why localStorage is Not a Database

In our initial release, we relied on the browser-level localStorage inside the WebView to store merged historical traffic logs. This was a bad engineering decision for three reasons:

  1. OS Cache Purging: WebView storage is tied to browser caches. On macOS and Windows, if the system disk runs low, the OS daemon (cache_delete) can automatically purge WebView folders without warning. Cache-cleaning utilities (like CCleaner or CleanMyMac) also wipe these out.
  2. The 5MB Limit: Standard localStorage has a hard limit of 5MB. If a user tracked 20+ repositories over a year, the JSON string would hit the quota limit, throwing QuotaExceededError.
  3. Volatile Backups: We provided JSON exports, but without a native filesystem reader/writer, restoring those backups across different devices was painful.

๐Ÿ› ๏ธ The Solution: Tauri Rust-backed Native File Storage

To solve this, we bypassed the WebView entirely for data persistence and wrote a custom Rust file cache.

1. The Rust Backend Commands

We implemented two native Tauri commands in Rust to read and write repository statistics directly to the OS Application Data directory (which is safe from OS cache sweepers):

// src-tauri/src/lib.rs
use std::fs;

#[tauri::command]
fn save_traffic_data(app_handle: tauri::AppHandle, repo_key: String, data_type: String, data: String) -> Result<(), String> {
    let mut path = app_handle.path().app_data_dir().map_err(|e| e.to_string())?;
    fs::create_dir_all(&path).map_err(|e| e.to_string())?;

    // Escape slashes to make safe filenames (e.g., owner/repo -> owner__repo)
    let safe_repo_key = repo_key.replace("/", "__");
    let filename = format!("traffic_{}_{}.json", data_type, safe_repo_key);
    path.push(filename);

    fs::write(path, data).map_err(|e| e.to_string())?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

2. The Asynchronous Frontend Caching Layer

We refactored mergeTrafficData in TypeScript to be asynchronous. When running inside Tauri, it invokes the Rust backend to read/write JSON files. For web browser development environments, it gracefully falls back to localStorage:

// src/lib/storage.ts
import { isTauri, invoke } from '@tauri-apps/api/core';

export async function mergeTrafficData(repoKey: string, type: 'views' | 'clones', incomingData: TrafficDataPoint[]) {
  let storedData: TrafficDataPoint[] = [];
  const runningInTauri = isTauri();

  if (runningInTauri) {
    try {
      const storedStr = await invoke<string>('load_traffic_data', { repoKey, dataType: type });
      storedData = storedStr ? JSON.parse(storedStr) : [];
    } catch (e) {
      // Fallback
      storedData = JSON.parse(localStorage.getItem(`gittraffic_${type}_${repoKey}`) || '[]');
    }
  }

  // O(1) merge logic using a Map...

  if (runningInTauri) {
    await invoke('save_traffic_data', { repoKey, dataType: type, data: JSON.stringify(mergedData) });
  }

  return mergedData;
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”„ Bypassing GitHub's API Rate Limits: Rotational Syncing

GitHub fine-grained PATs are limited to 5,000 requests per hour. In our initial background sync setup, we synced the top 10 repositories. But for power users with 50+ repositories, the remaining 40 repos would eventually fall off the 14-day cliff because syncing everything at once risked hitting rate limits.

To solve this, we implemented a sliding-window rotational queue:

// src/hooks/useBackgroundSync.ts
const BATCH_SIZE = 10;
const lastSyncedIndexStr = localStorage.getItem('background_sync_last_index');
let startIndex = lastSyncedIndexStr ? parseInt(lastSyncedIndexStr, 10) : 0;

// Sort consistently so rotation mappings remain stable
const sortedRepos = repos.sort((a, b) => b.stargazers_count - a.stargazers_count);
const endIndex = Math.min(startIndex + BATCH_SIZE, sortedRepos.length);
const batchRepos = sortedRepos.slice(startIndex, endIndex);

// Sync only this batch...
for (const repo of batchRepos) {
  await getRepoTrafficViews(repo.owner.login, repo.name);
  await getRepoTrafficClones(repo.owner.login, repo.name);
}

// Save next offset index
const nextStartIndex = endIndex >= sortedRepos.length ? 0 : endIndex;
localStorage.setItem('background_sync_last_index', nextStartIndex.toString());
Enter fullscreen mode Exit fullscreen mode

This simple shift ensures that 100% of a user's repositories are eventually backed up, rotating through the list silently in the background without trigger limits.


๐Ÿงน Zero-Dependency Data Purging in Rust

We also added a Custom Data Retention Policy (Keep Forever, 30 Days, 90 Days, etc.).

To keep the Rust binary lean, we wanted to avoid importing heavy dates/time crates like chrono. Because ISO-8601 strings (YYYY-MM-DDTHH:MM:SSZ) sort lexicographically, we wrote a zero-dependency date filter in Rust using simple string comparisons:

// src-tauri/src/lib.rs
arr.retain(|item| {
    if let Some(timestamp) = item.get("timestamp").and_then(|t| t.as_str()) {
        // String comparison is fully compatible with ISO-8601 sorting!
        timestamp >= threshold_str.as_str()
    } else {
        true
    }
});
Enter fullscreen mode Exit fullscreen mode

We calculate the ISO threshold string in JavaScript, pass it to Rust, and let Rust do the heavy lifting of reading, filtering, and rewriting the files.


๐Ÿท๏ธ Dyn-Binding App Versioning

We also got rid of hardcoded version numbers in the UI. By enabling resolveJsonModule: true in tsconfig.json, we dynamically import the version directly from our Node configs:

import packageInfo from '../../package.json';

// In UI rendering
<p>Version {packageInfo.version}</p>
Enter fullscreen mode Exit fullscreen mode

Now, version numbers in our About dialog, Node environment, and Tauri configs are always perfectly in sync.


๐ŸŽฏ Conclusion & v0.4.1 Release

Tauri v2 combined with React Query makes building robust, local-first applications incredibly fun. Moving away from localStorage to native Rust file I/O has made Repo-rter significantly faster, immune to OS-level cache wipes, and ready for long-term historical logging.

You can check out the source code, see the Tauri setup, or download the latest release (v0.4.1) here:

๐Ÿ‘‰ Repo-rter GitHub Repository

I'd love to hear your thoughts on this architecture! How do you handle sync and local storage in your desktop apps? Let me know in the comments! ๐Ÿ’ป๐Ÿ‘‡

Top comments (0)