Your Next.js Proxy Is Probably Adding 300ms to Every Request
How to build a fast, edge-compatible proxy.ts in Next.js App Router — stateless JWT auth with jose, HTTP-based Redis rate limiting, and a matcher config that stops running on every image request.
Your Next.js Proxy Is Probably Adding 300ms to Every Request
If your proxy.ts file makes a database call, you've already lost 150-350ms before your page even starts rendering. That's not an edge case — it's the single most common reason Next.js apps ship slow TTFB despite doing everything else right.
The proxy file (what most of us still call middleware out of habit) runs before every request completes. That makes it a great place for auth checks, geolocation redirects, and rate limiting. It also makes it a terrible place for anything that touches the network synchronously, because whatever latency you add there gets charged to every single request that matches your config.
Here's how to build one that costs less than 3ms.
The three ways proxy files go wrong
Three patterns show up over and over in production Next.js apps, and they compound:
- Network latency waterfalls. Calling an external API or database directly inside the proxy on every page load, adding 150-400ms to TTFB before anything else happens.
- No matcher filtering. The proxy runs on static assets,
_next/staticbundles, and favicon requests it was never meant to touch. - Edge runtime incompatibility. Importing Node built-ins like
fs,crypto, or a native database driver — none of which exist in the V8-based edge runtime, so they fail at request time, not build time.
Fix these three and you've solved most of what makes edge proxies slow.
Verify sessions with zero network hops
The fix for the first problem is to stop checking sessions against a database at all. Instead, sign a JWT when the user logs in, and verify it cryptographically, in-memory, on every request after that. No round trip, no waterfall.
jose is the library for this — it's built for edge runtimes and doesn't drag in Node-specific crypto internals.
// proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET_KEY || 'default-secret-key-32-chars-long'
);
const PUBLIC_PATHS = ['/login', '/signup', '/api/auth', '/pricing'];
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip public unauthenticated routes
if (PUBLIC_PATHS.some((path) => pathname.startsWith(path))) {
return NextResponse.next();
}
// Extract session token from cookies
const sessionToken = request.cookies.get('session_token')?.value;
if (!sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
try {
// Verify the JWT in-memory — zero network latency
const { payload } = await jwtVerify(sessionToken, JWT_SECRET, {
algorithms: ['HS256'],
});
// Forward verified user claims downstream via request headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.sub as string);
requestHeaders.set('x-user-role', (payload.role as string) || 'user');
return NextResponse.next({
request: { headers: requestHeaders },
});
} catch (error) {
// Expired or tampered token: bounce to login
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('session_token');
return response;
}
}
// Strict matcher: exclude static files, images, and internal Next.js assets
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
Notice the matcher does double duty here. It's not just an optimization bolted on afterward — it's what stops your auth check from running on every single image and font request on the page. Skip it and you're paying the (small, but nonzero) cost of a proxy invocation on assets that have nothing to do with auth.
Rate limit at the edge without a TCP client
Session checks aren't the only place teams reach for a database inside the proxy. Rate limiting is the other big one, and the same rule applies: a traditional Redis client opening a TCP socket on every request will blow your latency budget just as fast as a Postgres query would.
The fix is an HTTP-based Redis client. @upstash/ratelimit paired with @upstash/redis talks to Redis over REST instead of a persistent socket, which is what makes it edge-compatible in the first place.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Lightweight HTTP REST client, compatible with the edge runtime
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(20, '10 s'), // 20 requests per 10 seconds
});
export async function applyRateLimit(request: NextRequest): Promise<NextResponse | null> {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success, limit, remaining, reset } = await ratelimit.limit(`ratelimit_${ip}`);
if (!success) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
});
}
return null;
}
Call this from inside proxy.ts on the routes that need it — login endpoints and public APIs are the obvious candidates. Applying it globally is usually overkill and just adds a Redis round trip to routes that don't need protecting.
What each approach actually costs
Here's the tradeoff laid out plainly, because "just use JWTs" only makes sense once you see the gap:
| Strategy | Network hops | Added latency | Edge-compatible? | Core Web Vitals impact |
|---|---|---|---|---|
| Sync database session check | 1-2 | +150-350ms | No — driver fails | Severe TTFB degradation |
| External auth REST endpoint | 1 | +80-200ms | Yes | Moderate TTFB delay |
Stateless jose JWT verification |
0 | <3ms | Yes | Effectively none |
| Unfiltered static-asset matcher | 0 | +10ms per file | Yes | Unnecessary compute cost, no functional benefit |
The database check isn't just slower — it's not even a valid option on the edge runtime, since native drivers don't run there. If your proxy currently does this, it's not a performance tweak you're deferring, it's a bug you haven't hit yet.
The pattern that ties it together
Stateless verification, HTTP-based rate limiting, and a tight matcher aren't three separate optimizations you pick and choose from. They're the same principle applied three times: keep the proxy file's own execution cost close to zero, and push anything expensive to somewhere it can be cached or amortized.
Get the matcher wrong and you're running auth logic on your favicon. Get the JWT verification wrong (or skip it in favor of a database check) and you've reintroduced the exact latency waterfall the edge runtime was supposed to eliminate. Get the rate limiter wrong and you've added a TCP handshake to your login page.
None of these are hard to fix individually. The mistake is treating them as unrelated — sort out the matcher, then the auth strategy, then rate limiting, and there's genuinely nothing left in proxy.ts that touches the network on a cold path.
Accelerate your Frontend & Web Architecture Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Stop Overusing revalidatePath — Fix Stale Next.js Caches with Tags Instead
revalidatePath() purges entire route trees and wastes database calls. Here's how hierarchical revalidateTag() fixes stale ISR reads without the origin load.
Fixing React 19 Hydration Mismatches in Next.js App Router
The three most common causes of hydration mismatch errors in React 19 and Next.js App Router — localStorage reads, timezone-dependent dates, and browser extensions — with working fixes for each.
revalidateTag() Works Locally and Fails in Production: Fixing Next.js Cache Sync Across Multiple Nodes
Why revalidateTag() and revalidatePath() silently stop working once your Next.js App Router app runs on more than one server instance, and how to fix it.