Daeseon Yoo
Back to project
·Tech retro·2 min

Why publish-to-live used to take 60s, and how I got it to 3-5s

Production fs is a frozen build snapshot. Reading content through fs.readFile meant 60s of staleness per publish. Moved reads to GitHub Raw.

After fixing the EROFS write bug, publishing worked — but the post took ~60s to show up on the live site. The Vercel build was running, and only after it finished did the new slug start returning 200.

I had assumed revalidatePath() would fix this. It didn't. Here's why.

The actual mechanism

lib/posts.ts was reading content via fs.readFile("content/posts/en/<slug>.mdx"). In production, that filesystem isn't live — it's a snapshot taken at build time and packaged into the function bundle. New commits don't appear in it until the next build replaces the bundle.

revalidatePath only invalidates Next.js's ISR cache. It does not refresh the underlying filesystem. The cache invalidation fires, the next request hits the function, the function calls fs.readFile — and gets the same stale data back, just freshly served. Cache miss didn't help.

The fix

Replace fs.readFile with fetch(raw.githubusercontent.com/...). New file in lib/source.ts:

export async function readText(repoRelativePath: string) {
  if (githubConfigured()) {
    return await fetch(
      `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`,
      {
        headers: { Authorization: `token ${token}` },
        next: { revalidate: 30 },
      },
    ).then((r) => (r.ok ? r.text() : null));
  }
  // dev fallback to fs
  return await fs.readFile(localAbsPath(path), "utf-8");
}

Refactored lib/posts.ts, lib/projects.ts, lib/now.ts, lib/site-storage.ts to use it.

Now:

  1. Publish → GitHub commit.
  2. revalidatePath invalidates the page's ISR entry.
  3. Next request to that page → function runs → readText fetches from GitHub Raw → fresh data.
  4. Visible in ~3-5s.

The Vercel rebuild still runs in the background, but I don't wait for it. It mostly just refreshes the prerender for the new slug — a small latency win on the first cold request.

Commit: 8321b0a.

The mental model that fixed this

Two timelines:

fs.readFile belongs to build time even when it runs at request time, because the bytes it returns are from a build-time snapshot. fetch to a remote URL belongs to runtime — every call (modulo cache) is live.

This is now docs/architecture.md because it's the kind of thing I'd want a new collaborator to read before touching lib/source.ts.

What I'd do differently

I should have started with fetch-from-GitHub-Raw from day one. I picked fs.readFile because it's familiar and worked in dev. The 30 seconds of "let me just check the prod path mentally" would have saved a deploy roundtrip.

Pattern lesson: in Vercel/serverless land, default to thinking of fs as the build's leftover, not as live storage. If the data can change after deploy, don't read it through fs.