Instant Navigation in Web Apps: A Practical Guide Based on GitHub Issues Modernization
Learn to eliminate perceived latency with client-side caching, preheating, and service workers, using GitHub Issues modernization as a case study.
Introduction
When you're working through a backlog—opening an issue, jumping to a linked thread, then back to the list—latency isn't just a metric. It's a context switch. Even small delays add up, and they hit hardest at the exact moments developers are trying to stay in flow. GitHub Issues wasn't slow in isolation, but too many navigations paid the cost of redundant data fetching, breaking flow repeatedly. This guide shows you how to eliminate that perceived latency using the techniques we applied to modernize GitHub Issues: client-side caching with IndexedDB, a preheating strategy, and a service worker. You'll learn step by step how to replicate this pattern in your own data-heavy web app, turning slow navigations into instant ones.

What You Need
- Basic knowledge of web performance concepts (e.g., perceived latency, TTI, first paint)
- Familiarity with JavaScript and browser APIs (IndexedDB, Service Workers, Fetch API)
- A modern web application with navigations that fetch data (e.g., issue lists, dashboards, feeds)
- Browser DevTools for performance profiling
- Optional: A backend API that can return data in a cache-friendly format (e.g., JSON with IDs)
Step-by-Step Guide
Step 1: Define and Measure the Right Metric
Don't optimize raw server response time alone. Instead, focus on perceived latency—the time from user action (click, tap) to seeing meaningful content. For GitHub Issues, we targeted the time between clicking an issue link and seeing the issue detail page render with previously cached data. Use tools like Lighthouse or Performance API to measure 'First Paint with cached content' vs. 'Full load'. Set a goal: sub-100ms for instant feel.
Step 2: Build a Client-Side Caching Layer with IndexedDB
Store frequently accessed data locally so navigations don't always hit the network. Create an IndexedDB database with object stores for your data (e.g., issues, comments). Use a key-value pattern: key = entity ID, value = JSON blob. Update the cache on every successful fetch. For GitHub Issues, we cached issue details, list data, and metadata. Ensure you handle cache expiry (e.g., 5 minutes) and size limits (e.g., 50MB).
// Example: Caching issue data
async function cacheIssue(issueId, data) {
const db = await openDB('issues-cache', 1);
const tx = db.transaction('issues', 'readwrite');
await tx.store.put({ id: issueId, data, timestamp: Date.now() });
await tx.done;
}
Step 3: Implement a Preheating Strategy
Preheating means proactively fetching data likely to be needed soon, without user request. Based on user behavior (e.g., hovering over a link, scrolling through a list), prefetch the data and store it in IndexedDB. This improves cache hit rates. For Issues, when a user views a list, we prefetch details for the first few issues. Use requestIdleCallback or a small timeout to avoid jank. Be careful not to spam requests—use a priority queue.
Step 4: Introduce a Service Worker for Hard Navigations
Hard navigations (full page reloads) bypass client-side JavaScript cache. Register a service worker that intercepts fetch requests and serves cached data from IndexedDB if available. For GitHub Issues, the service worker listens to navigation requests for issue pages, checks the cache, and returns cached HTML or JSON. If not cached, it falls back to network and updates the cache. This makes even reloads feel instant.
// Service worker: serve cached issue page
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(serveFromCacheOrFallback(event.request));
}
});
Step 5: Revalidate in Background
Show cached data instantly, then fetch fresh data from the server in the background. This is key for perceived instantness. In your component, when a navigation occurs: (1) render from IndexedDB immediately, (2) fire a fetch request to the server, (3) update the cache and re-render if data changed. This pattern—stale-while-revalidate—ensures the user sees content immediately while staying up to date. Implement with a simple state machine: LOADING (from cache), READY (display cached), UPDATING (background fetch), FINAL (updated).

Step 6: Monitor and Iterate
Use real-user monitoring (RUM) to track perceived latency. GitHub Issues measured improvement in 'navigation time to interactive' from ~800ms to under 100ms. Set up alerts for cache miss rates and fallback times. A/B test different preheating strategies. Iterate based on usage patterns. Also be mindful of tradeoffs: increased memory usage, stale data risks, and complexity. Document your cache invalidation rules clearly.
Tips for Success
- Start with the highest-impact navigations. For Issues, the issue detail page was the most frequent path.
- Use a cache-first strategy judiciously. For mutable data, set short TTLs (e.g., 30 seconds) or use versioning.
- Test on low-end devices – IndexedDB operations can be slow on mobile. Use async and limit writes.
- Combine preheating with prefetching – Use
<link rel="prefetch">for resources, but preheating via service worker is more flexible. - Gracefully handle cache misses – Show a skeleton loader or spinner if the cache is empty, but aim for >90% hit rate.
- Monitor memory usage – IndexedDB can grow large. Implement automatic cleanup based on access recency.
- Consider edge cases – What happens when user clears cache? Service worker install/update? Handle with fallbacks.
- Step 1 and other steps above provide the blueprint. For deeper dive, explore the service worker section for hard navigation handling.
By following these steps, you can transform a slow, data-heavy web app into one that feels instant. The techniques used on GitHub Issues are directly transferable—you don't need a full rewrite. Start small, measure impact, and iterate. Your users will thank you.