← All articles

[ Blog ]

July 11, 2026

10 min read

Category: Insights

Why Neon Hits Its Limit on a Low-Traffic Site

Why is my Neon database hitting its limit on a low-traffic site? The cause is compute-hours, not traffic — fix it by caching reads and moving writes to Redis.

Next.jsNeonPostgresServerlessPrismaRedisPerformance
Why Neon Hits Its Limit on a Low-Traffic Site

One morning this site started throwing Can't reach database server. Not under load — it gets a few hundred visits a day. I opened the Neon dashboard expecting a bad connection string and instead found the compute quota exhausted, halfway through the month. If you've been asking why is my Neon database hitting its limit on a low-traffic site, the answer is almost never traffic. It's that serverless Postgres bills you for time the compute is awake, and something in your app is keeping it awake around the clock. Here's exactly what was doing that on my own stack, and the three-part fix that got the compute back to sleeping between edits.

Why is my Neon database hitting its limit on a low-traffic site

Neon's Free plan doesn't meter requests or rows — it meters compute-hours (CU-hours). You get 100 CU-hours per project per month, and the compute auto-suspends after 5 minutes of inactivity, a feature Neon calls scale-to-zero. That 5-minute window is the whole game. A 0.25 CU compute (the Free default) running non-stop burns about 0.25 × 730 = 182.5 CU-hours a month — nearly double the 100 you're given. So if anything in your app queries the database more often than once every 5 minutes, the compute never gets to suspend, and you blow past the quota around the middle of the month regardless of how few humans visited.

Traffic barely enters into it. A single background pinger, a health check, an uptime monitor, or — as in my case — the app's own rendering pipeline hitting the DB on a schedule is enough to pin the compute at 100% uptime. The bill isn't "per visitor." It's "per minute the lights are on."

The two culprits, both bypassing cache

When I traced every path that touched Postgres, two were firing far more than a low-traffic site should:

  1. Direct DB reads that were never wrapped in a cache. A hand-rolled Prisma reader here, a prisma.page.findMany() in the sitemap route there — each one runs a fresh query on every request and every ISR regeneration. Crawlers and prefetches alone keep these warm well inside the 5-minute window.
  2. A write on every single request. The view counter did a prisma.upsert() per page view to bump a number. This is the nastier one, because caching can't save a write. A read you can serve from cache; a write, by definition, has to reach the database — and every write wakes the compute back up even if it had just started to idle.

Reads you can fix by not asking the database in the first place. Writes you fix by not sending them to Postgres at all. That's the whole strategy.

Fix 1: cache the reads with unstable_cache (and revive your Dates)

Every read that doesn't need to be live should be served from Next.js's unstable_cache, tagged so a CMS edit can bust it on demand. The key detail: set revalidate: false so the entry never expires on a timer — it only refreshes when you explicitly invalidate the tag. That turns "one query per request" into "one query per edit."

import { unstable_cache } from 'next/cache';
import { prisma } from '@/lib/prisma';

export const getPublishedPosts = unstable_cache(
  async () => {
    const rows = await prisma.post.findMany({
      where: { published: true },
      orderBy: { date: 'desc' },
    });
    // unstable_cache serializes through JSON — Date becomes a string.
    // Revive it so the rest of the app still gets real Date objects.
    return rows.map((r) => ({ ...r, date: new Date(r.date) }));
  },
  ['published-posts'], // cache key parts
  { tags: ['posts'], revalidate: false }, // never time-expire; bust via tag
);

Two things bite people here. First, unstable_cache serializes its return value through JSON, so any Date, Decimal, or Map comes back out the other side as a string or a plain object. If your components call .toISOString() on what they think is a Date, they'll throw after you add caching, not before — revive those fields on the way out, like the new Date(r.date) above. Second, when content changes in the CMS, bust the tag so the next request repopulates it:

import { revalidateTag } from 'next/cache';

// call this from your CMS mutation / webhook
revalidateTag('posts');

Now the sitemap, the blog index, and every CMS-backed page hit Postgres once after an edit, then serve from cache until the next edit. If you're wondering why these reads weren't cached to begin with, that's the same class of bug I broke down in why Next.js keeps hitting your CMS on every request — App Router doesn't cache by default, and it's easy to ship a reader that silently queries on every hit.

Fix 2: move the write off Postgres to Redis

The view counter is the one caching can't touch, so it has to leave Postgres entirely. A counter is the perfect Redis workload — HINCRBY is atomic, O(1), and never touches your database compute. I used Upstash because it's HTTP-based and bills per-request, which fits Vercel's serverless model without a connection pool.

The important production detail: ship it with a Postgres fallback so you can deploy the code before the KV store is provisioned, and so a Redis blip degrades to a slow write instead of a lost one.

import { Redis } from '@upstash/redis';
import { prisma } from '@/lib/prisma';

const redis = process.env.UPSTASH_REDIS_REST_URL ? Redis.fromEnv() : null;

export async function incrementView(slug: string) {
  if (redis) {
    // atomic, O(1), never wakes Postgres
    return redis.hincrby('post:views', slug, 1);
  }
  // fallback: still works before KV is provisioned, or if Redis is down
  await prisma.post.update({
    where: { slug },
    data: { views: { increment: 1 } },
  });
}

Reads of the count come straight from Redis too, so the hot path for the counter never involves Postgres at all. You can flush the accumulated counts back into Postgres on a schedule (a nightly cron) if you want durable totals — but that's one query a day instead of one per pageview. That single change is what let my compute finally start hitting the 5-minute idle window.

Fix 3: rate-limit the public endpoints so bots can't hold the DB awake

Even with reads cached and writes offloaded, a public API route or the view-counter endpoint can be hammered by bots — and every uncached hit is a chance to wake the compute or drain quota. A rate limiter in front of the hot endpoints caps that. @upstash/ratelimit runs on the same Redis, and two options matter for correctness under serverless:

  • ephemeralCache deduplicates within a single warm function instance, so a burst doesn't fan out into a Redis call per request.
  • Fail-open: if the limiter itself errors (Redis unreachable), you allow the request rather than 500 the whole site. A rate limiter should never be a new single point of failure.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(20, '10 s'),
  ephemeralCache: new Map(), // dedupe within one warm instance
  analytics: false,
});

export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anonymous';

  try {
    const { success } = await ratelimit.limit(ip);
    if (!success) {
      return new Response('Too many requests', { status: 429 });
    }
  } catch {
    // fail OPEN — a limiter outage must not take the route down
  }

  // ... handle the request
}

Don't switch vendors — switch your query pattern

Every thread on this ends with someone saying "just move off Neon." I looked at the alternatives before I fixed the actual bug, and none of them address the real problem:

OptionWhat it actually changesThe catch
Cloudflare D1SQLite on WorkersWrong fit for Vercel + Prisma; you'd rewrite the dialect and lose Postgres features
Supabase FreeStill PostgresTrades compute-hours for a pause after 7 days idle — different trap, same category
Self-host on a VPSFull controlYou now own pooling, backups, security patching, and uptime
Neon Launch (paid)Same DB, higher quotaCosts money — but zero migration, and cheaper than a VPS once you price in ops time

The compute-hours problem is a query-pattern problem, not a platform problem. Moving to D1 or Supabase without fixing the uncached reads and the per-request write just relocates the same behavior to a database with a different failure mode. Fix the pattern first; the quota stops being a problem on the platform you already have. If you genuinely outgrow the Free plan after that, Neon's Launch tier needs zero migration and — once you honestly price in pooling, backups, and uptime — comes out cheaper than self-hosting the same thing on a DigitalOcean droplet.

How to verify it worked

Don't trust the code — watch the compute. After deploying all three fixes, the Neon dashboard's compute graph should show the bar dropping to idle / suspended between edits, instead of a flat line pinned at active. That flat-to-sawtooth flip is the proof: the compute is finally reaching its 5-minute idle window and scaling to zero, which is the entire point of serverless Postgres. Give it a day of real traffic and check the CU-hours trend — mine went from "exhausted by the 15th" to a rounding error.

This is the same quiet-infrastructure-cost audit I wrote about in the hidden cost of a slow website, and it's the kind of Next.js and serverless work I take on — see the frontend and performance services I offer if your database bill doesn't match your traffic.

FAQ

Does a low-traffic site really exhaust Neon's free compute hours?

Yes, and traffic is a red herring. Neon Free gives 100 CU-hours/month and suspends the compute after 5 minutes idle. A 0.25 CU compute running non-stop uses ~182.5 CU-hours — so anything that queries the DB more than once every 5 minutes (a cron, a monitor, an uncached read, a per-request write) keeps it awake 24/7 and blows the quota regardless of visitor count.

Why can't caching fix my view counter?

Because a counter is a write, and caching only helps reads. Every increment has to reach the database, and every write wakes the compute. The fix is to move the write off Postgres entirely — an atomic HINCRBY in Redis costs nothing against your DB compute, and you can batch-flush totals back to Postgres on a schedule if you need durable numbers.

What does revalidate: false do in unstable_cache?

It tells Next.js the cache entry never expires on a timer — it only refreshes when you call revalidateTag() with a matching tag. That's ideal for CMS-backed content: the database is queried once after an edit, then not again until the next edit, instead of on a fixed TTL that re-queries even when nothing changed.

Should I switch from Neon to Cloudflare D1 or Supabase?

Usually not, if the symptom is exhausted compute-hours. D1 is SQLite on Workers — a poor fit for a Vercel + Prisma + Postgres app. Supabase Free is still Postgres but pauses after 7 days idle, a different trap in the same category. The root cause is your query pattern (uncached reads, per-request writes); fix that and the quota problem disappears on the platform you already run.

Related posts