Skip to main content
← All posts

Building Nekomori: Sync Without the Pain

Nekomori is an anime schedule tracker and personal watchlist manager. On the surface it sounds simple: pull data from an anime API, show it in a list, let the user mark episodes as watched. Two weeks into building it I had something that technically worked but felt terrible to use — slow, flaky, and leaking rate limit errors into the UI. This is the story of how I fixed it.

The Problem

The original architecture was naive: every page load triggered a fresh API call to the AniList GraphQL API. View your watchlist — API call. Check if a new episode is out — API call. Mark something as watched — API call to update, followed by another to re-fetch.

AniList's API has a rate limit of 90 requests per minute. That sounds like plenty until you realize a single list view could fan out into a dozen queries for individual media details. With 40 shows on my test account, I was hitting the cap inside three navigations.

The UX consequence was worse than the numbers: every interaction had a spinner, and marking a show as watched felt like submitting a form to a distant server rather than checking a checkbox in a local app.

The Architecture Decision

The fix was to stop thinking of the API as a live data source and start treating it as a sync target.

The core principle is local-first state with background sync:

  1. The watchlist state lives in a local database (SQLite via Drizzle ORM in the Next.js API routes).
  2. The app reads exclusively from local state — no API calls on page render.
  3. A background sync job refreshes stale data from AniList on a schedule, well within rate limits.
  4. Mutations (marking episodes watched, updating watch status) update local state immediately and queue a sync operation — they don't wait for the API.

This separates concerns cleanly: the UI is always fast because it reads from local storage; correctness is eventually guaranteed by the sync layer.

The Implementation

The sync layer has three components: a stale-data scheduler, a rate-limited API client, and an optimistic mutation handler.

The scheduler marks records as stale after a configurable TTL (24 hours for airing shows, 7 days for completed ones). A sync worker runs on a cron-like schedule, picks up stale records in batches, and refreshes them:

interface SyncRecord {
  animeId: number
  lastSyncedAt: Date
  staleSince: Date | null
  syncStatus: 'idle' | 'pending' | 'syncing' | 'error'
}
 
async function syncStalePosts(batchSize = 10): Promise<void> {
  const stale = await db
    .select()
    .from(syncQueue)
    .where(
      and(
        eq(syncQueue.syncStatus, 'pending'),
        lt(syncQueue.staleSince, new Date(Date.now() - STALE_TTL_MS))
      )
    )
    .limit(batchSize)
 
  for (const record of stale) {
    try {
      await db
        .update(syncQueue)
        .set({ syncStatus: 'syncing' })
        .where(eq(syncQueue.animeId, record.animeId))
 
      const freshData = await anilistClient.getMediaDetails(record.animeId)
      await upsertAnimeRecord(freshData)
 
      await db
        .update(syncQueue)
        .set({
          syncStatus: 'idle',
          lastSyncedAt: new Date(),
          staleSince: null,
        })
        .where(eq(syncQueue.animeId, record.animeId))
    } catch (err) {
      await db
        .update(syncQueue)
        .set({ syncStatus: 'error' })
        .where(eq(syncQueue.animeId, record.animeId))
      console.error(`Sync failed for anime ${record.animeId}:`, err)
    }
  }
}

The batch size of 10 combined with a 1-second delay between batches keeps the request rate well under AniList's limit even when 200+ records need syncing after a cold start.

Optimistic Updates

For watch-status changes — the most frequent mutation — I wanted zero latency. The user taps "watched", the checkmark should be immediate.

The pattern is straightforward with SWR's mutate:

async function markEpisodeWatched(
  animeId: number,
  episode: number
): Promise<void> {
  // Optimistically update local UI cache
  await mutate(
    `/api/watchlist/${animeId}`,
    (current: WatchlistEntry | undefined) => {
      if (!current) return current
      return {
        ...current,
        watchedEpisodes: Math.max(current.watchedEpisodes, episode),
        updatedAt: new Date().toISOString(),
      }
    },
    { revalidate: false }
  )
 
  try {
    // Write to local DB and queue the sync
    await fetch(`/api/watchlist/${animeId}/watched`, {
      method: 'POST',
      body: JSON.stringify({ episode }),
      headers: { 'Content-Type': 'application/json' },
    })
  } catch (err) {
    // Revert on failure
    await mutate(`/api/watchlist/${animeId}`)
    throw err
  }
}

The key detail: revalidate: false prevents SWR from immediately re-fetching after the optimistic update. The server write happens in the background. If it fails, we revert by triggering a normal revalidation. The user sees the update instantly in 99% of cases; the 1% gets a visible rollback which is honest rather than silent.

Lessons Learned

Rate limits force good architecture. AniList's rate limit felt like a constraint, but it pushed me toward a design that's actually better for the user. Request-on-every-render is never the right pattern for external APIs.

Local-first is not offline-first. Nekomori still requires a network connection to sync — it's not a true offline app. But "reads from local, writes sync in background" dramatically reduces the number of user-facing network waits. That's most of the UX win without the full complexity of conflict resolution.

Optimistic updates need rollback paths. It's tempting to write the optimistic update and assume the server write succeeds. Handling the failure case explicitly — and reverting cleanly — is what separates a polished app from one that gets mysteriously out of sync.

The sync layer took about a day to get right, but it made the entire app feel like it was built well. That's the kind of invisible infrastructure work that's worth writing about.