loke.dev
Header image for Sec-Purpose Is a Performance Cheat Code

Sec-Purpose Is a Performance Cheat Code

Distinguishing background pre-fetches from real user navigation allows you to warm up caches and optimize expensive queries before a user even decides to click.

· 4 min read

Ever looked at your server logs and wondered why you’re getting hammered with expensive database queries for pages the user never actually landed on?

Browsers are getting incredibly aggressive. Between <link rel="prefetch"> and the newer, even more ambitious Speculation Rules API, Chromium-based browsers are basically trying to predict the future. They'll start downloading the next page they *think* a user might click.

This is a double-edged sword. For the user, the site feels instant—the "cheat code" for perceived performance. For your server, it’s a potential DOS attack from your own customers. If you treat every prefetch like a real navigation, you’re wasting CPU cycles, skewing your analytics, and potentially triggering side effects (like "last seen" timestamps) that shouldn't happen yet.

Meet Sec-Purpose: The Truth Serum

The browser isn't actually trying to trick you. It’s quite polite about its intentions; it just sends a specific header along with these "guess-work" requests.

When a browser fetches a resource ahead of time, it attaches the Sec-Purpose header. Usually, the value is prefetch.

GET /blog/how-to-optimize-everything HTTP/1.1
Host: your-site.com
Sec-Purpose: prefetch
...

If you see that header, you know the user hasn't actually clicked the link yet. They’re just hovering, or the browser has decided that because they’re on the "Pricing" page, there’s a 90% chance they’ll click "Sign Up" next.

Don't Pay the "Iron Price" for a Guess

I’ve seen production apps where a prefetch triggers a massive PDF generation or a heavy data-export query. That’s a disaster. If the user doesn't click, you just burnt a dollar of compute for zero value.

Here is how you can handle this in a standard Express or Node.js environment to guard your heavy lifting:

app.get('/expensive-dashboard', async (req, res) => {
  const isPrefetch = req.headers['sec-purpose'] === 'prefetch' || 
                     req.headers['purpose'] === 'prefetch'; // Legacy support

  if (isPrefetch) {
    // Strategy: Return early with just the "fast" stuff or a light version
    console.log('User might come here. Warming up the cache, but skipping the PDF gen.');
    
    // Maybe just pre-warm the Redis cache for the main data query
    await warmCacheForUser(req.user.id);
    
    // Return a 200, but perhaps don't do the 2-second long processing
    return res.status(200).send('Pre-warmed and ready.');
  }

  // Real navigation logic here
  const heavyData = await generateMassiveReport(req.user.id);
  res.render('dashboard', { heavyData });
});

Strategy 1: Save Your Analytics

Nothing ruins a conversion-rate report faster than 500 "page views" that were actually just a browser prefetching a link that was never clicked.

If you're doing server-side event tracking (like hitting a Segment or Mixpanel API from your backend), you must check for Sec-Purpose. If it's a prefetch, don't fire the "Page Viewed" event. Wait until the user actually arrives.

function logPageView(req) {
  if (req.headers['sec-purpose'] === 'prefetch') return;

  analytics.track({
    userId: req.user.id,
    event: 'Page Viewed',
    properties: { path: req.path }
  });
}

Strategy 2: The "Warm Up" Pattern

Instead of just ignoring the prefetch, use it as a signal to move data closer to the "edge."

Imagine a search results page. If the browser prefetches the first result, you shouldn't just do nothing. You can use that idle time to fetch that specific item from your slow primary DB and put it into a fast Redis cache.

When the user *actually* clicks—which feels like 0ms to them—the data is already sitting in memory, ready to go.

// Example: Using prefetch to warm a cache
async function handleProductPage(req, res) {
  const { productId } = req.params;
  const isPrefetch = req.get('Sec-Purpose') === 'prefetch';

  if (isPrefetch) {
    // Only fetch the data and stick it in Redis
    const product = await db.products.findUnique({ where: { id: productId } });
    await redis.set(`product:${productId}`, JSON.stringify(product), 'EX', 60);
    
    // We don't need to render the whole HTML template or 
    // fetch related "You might also like" items yet.
    return res.status(204).end(); 
  }

  // Real request: Try cache first, then DB
  const cachedData = await redis.get(`product:${productId}`);
  const product = cachedData ? JSON.parse(cachedData) : await db.products.findUnique(...);
  
  res.render('product-detail', { product });
}

A Few "Gotchas" to Keep in Mind

1. Vary Header: If you are using a CDN or a reverse proxy (like Nginx or Varnish), and you return different content for a prefetch vs. a real request, you must send Vary: Sec-Purpose. If you don't, your CDN might cache the "lightweight" prefetch response and serve it to the user when they actually navigate to the page. That would be bad.
2. It's not just Chrome: While Chromium is the main driver behind the Speculation Rules, other browsers have different ways of hinting. Always check for both Sec-Purpose: prefetch and the older Purpose: prefetch.
3. Don't break the UI: If you return a partial response or a 204 No Content for a prefetch, ensure that your application logic handles the "real" request that follows correctly. The browser will usually make a fresh request when the user actually clicks, even if it prefetched earlier.

The Bottom Line

Understanding Sec-Purpose turns a potential resource drain into a surgical performance tool. You stop paying for "maybe" and start optimizing for "definitely." By distinguishing between the two, you can provide that "instant" web experience without setting your server bills on fire.

Next time you're building an expensive route, check the headers. Your database will thank you.