← BlogTech case · performance

The Cost of a Second Round Trip

Five unglamorous fixes to a Next.js + Postgres dashboard, and the one habit buried in almost every API route that was quietly taxing every click.

August 26, 2026·7 min read·Next.js · Drizzle · Neon Postgres · Clerk
56 / 90API routes paying a redundant DB lookup on every call
N → 1sequential queries collapsed into one, worst case
0new infrastructure — no cache server, no new service

The brief was simple and a little vague, the way these briefs usually are: make the app feel faster. Not a specific page, not a specific complaint — just a general sense that clicking around the dashboard should feel more instant than it did.

That's a bad brief to profile against, because "feels slow" doesn't point at a flame graph. So instead of reaching for Lighthouse or a synthetic benchmark, I did something slower and, it turned out, more useful: I read the code that actually runs when a real click happens — starting at the API route handler and following it down to the database driver — across every part of the app a user touches in a normal session.

That's how I found the pattern. Not in one slow endpoint, but in almost all of them, wearing the same disguise each time.


Fix 01

The lookup nobody asked for

Every API route in the app starts the same way: confirm who's asking, then do the actual work. The "who's asking" part goes through Clerk for the session, then a database query to turn that Clerk ID into the app's own user row — because almost everything downstream (folders, pages, tags, permissions) is scoped by that internal ID, not Clerk's.

Nothing wrong with that shape on its own. What made it expensive is the database driver underneath: the app talks to Neon over HTTP, which means every query is its own HTTPS round trip— there's no persistent connection sitting open, amortizing the cost. That's the right call for a serverless deployment, but it means the auth lookup isn't cheap. It's a full network hop, and it happens before the query you actually care about even starts.

I grepped for it. 56 of the app's 90 API route files called this same helper, meaning most interactions in the dashboard — opening a folder, saving a page, starring a company — were paying for two sequential round trips to the database when the second one was doing all the real work.

A typical PATCH request, before / after

Before
resolve user
run query
After
cache hit — 0ms
run query

The fix isn't clever — it's a 30-second in-memory cache, keyed by Clerk ID, plus a guard that de-duplicates concurrent lookups for the same user (a dashboard page load fires several API calls at once; without the guard, they'd all race to resolve the same user separately). Vercel's Fluid Compute reuses function instances across requests, so a cache that lives for the life of a warm instance actually pays off for the common case: several actions from the same person, seconds apart.

src/lib/api-helpers.ts
const dbUserCache = new Map<string, {user: User; expiresAt: number}>()
const inFlight = new Map<string, Promise<User | null>>()
const TTL_MS = 30_000

export async function getAuthenticatedDbUser() {
  const {userId: clerkUserId} = await auth()
  if (!clerkUserId) return null

  const cached = dbUserCache.get(clerkUserId)
  if (cached && cached.expiresAt > Date.now()) return cached.user

  if (inFlight.has(clerkUserId)) return inFlight.get(clerkUserId)!

  const fetchPromise = (async () => {
    const [dbUser] = await db.select().from(users)
      .where(eq(users.clerkUserId, clerkUserId)).limit(1)
    if (dbUser) dbUserCache.set(clerkUserId, {user: dbUser, expiresAt: Date.now() + TTL_MS})
    return dbUser ?? null
  })()

  inFlight.set(clerkUserId, fetchPromise)
  try { return await fetchPromise } finally { inFlight.delete(clerkUserId) }
}

The one thing this needed that a blind cache wouldn't: an explicit invalidateDbUserCache() call on the one write path where a stale cached row could actually mislead the app — the profile update route that changes onboarding step, nickname, and role. Everywhere else, the cached row is only ever read for its ID, so a 30-second staleness window is invisible.


Fix 02

Caching the things that don't change

The list of supported tech stacks (React, Postgres, Kubernetes, and so on) is admin-curated and changes maybe once a month. It's also read constantly — every onboarding step, every "add a stack" flow — and until now it hit the database every single time, with zero HTTP caching on the response.

This one didn't need a cache server — Next.js already ships a data cache for exactly this. Wrapping the query in unstable_cache with an hour-long revalidation window, plus a matching Cache-Control header, means most requests for this data never reach the function at all — the CDN answers them.


Fix 03

Recomputing the same answer, over and over

One dashboard view collects every note the user has ever flagged as "important" across their entire account. The original implementation did exactly what that sentence describes, literally: fetch every page, parse every page's full rich-text content as JSON, scan each one for markers, every single time the view opened. Fine for a new account. Not fine for someone three years in with eight hundred pages.

The fix keeps the expensive path but guards it behind a cheap one. Instead of asking "what are the important snippets," the first question is "did anything change since I last computed this" — answered with a lightweight query for just page IDs and their updated_attimestamps, turned into a fingerprint. If the fingerprint matches what's cached, the parse never runs.

src/app/api/important-snippets/route.ts — the gate
const cached = snippetCache.get(dbUser.id)
if (cached && cached.expiresAt > Date.now()) return NextResponse.json(cached.snippets)

const [pageStamps, folderStamps] = await Promise.all([
  db.select({id: pages.id, updatedAt: pages.updatedAt}).from(pages).where(eq(pages.userId, dbUser.id)),
  db.select({id: folders.id, name: folders.name}).from(folders).where(eq(folders.userId, dbUser.id)),
])
const signature = buildSignature(pageStamps, folderStamps)

if (cached && cached.signature === signature) {
  cached.expiresAt = Date.now() + CACHE_TTL_MS
  return NextResponse.json(cached.snippets)  // nothing changed — skip the parse entirely
}
// ...only past this point do we fetch full content and parse it

Fix 04

A loop with no upper bound

Creating a folder checks whether its slug already exists and, if so, appends -1, -2, and so on until it finds one that's free. The original code did that check with a while (true)loop — one database round trip per attempt. Rename "Notes" to something already taken five times over, and you've made five sequential trips to the database for what should be one.

before — one query per attempt
let uniqueSlug = `${base}-${counter}`
while (true) {
  const hit = await db.select()
    .from(folders)
    .where(eq(folders.slug, uniqueSlug))
  if (!hit[0]) break
  counter++
  uniqueSlug = `${base}-${counter}`
}
after — one query, total
const rows = await db.select({slug: folders.slug})
  .from(folders)
  .where(like(folders.slug, `${base}%`))

const taken = new Set(rows.map(r => r.slug))
let n = 1
while (taken.has(`${base}-${n}`)) n++

Fetch every slug that could possibly collide in one query, then resolve the free one in memory. As a side effect, this also fixed a latent bug in the old counter logic — it split the slug on hyphens to build the next candidate, which silently mangled any folder name that already contained a hyphen of its own.


Fix 05

An index for the query the search bar was actually running

Search matches on a leading wildcard — ilike('%query%')— which a standard B-tree index can't serve; Postgres has to walk every row already filtered down to that user. At today's data volume nobody notices. It's the kind of thing that degrades quietly as accounts accumulate content, and by the time it's noticeable it's a production incident instead of a migration file.

migrations/0029_search_trigram_indexes.sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX IF NOT EXISTS idx_pages_title_trgm
  ON pages USING gin (title gin_trgm_ops);
-- ...repeated for folders.name, videos.title, articles.title,
--    resources.title, companies.name

Fix 06

Hovering is a signal nobody was listening to

Everything above makes the server answer faster. But a user doesn't experience server time in isolation — they experience the gap between the click and the paint. And there's a piece of that gap the server-side work can't touch: the moment a cursor sits on a sidebar link, deciding, before the click ever lands.

The sidebar's data hooks already had a "have I fetched this yet" flag — built for a totally different reason, to stop the app re-fetching folders it already has in memory. That flag turns out to be exactly the gate a prefetcher needs. So the fetch now starts on onMouseEnter, writes into the same store the view reads from, and gets out of the way — de-duplicated against repeat hovers, silent on failure, because it's an optimization, not a feature; if it fails, the real fetch on click just does its normal job.

Folder click, before / after prefetch

Before
hover, deciding
click → fetch
paint
After
hover → fetch starts
click — data's already there
paint
FolderItemSimplified.tsx
<div
  onMouseEnter={() => prefetchFolderPages(folder.id)}
  onClick={handleFolderClick}
  ...
>
Tried it right after shipping: hovered a folder, clicked half a second later — it opened with no loading flicker. That's the whole trick. The data just had a head start.

What I'd tell someone about to do this to their own app

  • Trace a real request path before profiling. The repeated tax across forty files is worth more than one expensive query in one file.
  • If your database driver charges per round trip — HTTP transport, serverless, whatever — count round trips before you count milliseconds. They're the same currency, but easier to see.
  • Cache what changes rarely before you cache what changes often. It's lower-risk and the win is just as real.
  • Recomputation is invisible until it's on a view someone opens daily and their account has grown. Fingerprint before you parse.
  • A client-side cache only helps if it's warm before the click happens. Hover is free real estate — almost nothing uses it.
Every fix here is structural — something I can explain and defend line by line. I haven't run it through a profiler with real numbers yet, so treat this as the mechanism, not a "we cut latency 40%" claim. The mechanism is sound; the measurement is next.
DevRecall — engineering notes