Edge runtime, V8 isolates, міф про cold start, гео-роутинг, A/B тестування, автентифікація на edge та чому я деякі речі переніс назад на Node.js. Збалансований погляд на edge computing.
Слово «edge» зустрічається повсюди. Vercel про нього говорить. Cloudflare говорить. Deno говорить. Кожна конференційна доповідь про веб-продуктивність неминуче згадує «виконання на edge» так, ніби це магічне заклинання, що робить ваш застосунок швидким.
Я повірив у це. Я переніс middleware, API-маршрути і навіть деяку логіку рендерингу на edge runtime. Деякі з цих рішень були геніальними. Інші я тихенько повернув назад на Node.js через три тижні, після того як о другій ночі дебажив помилки пулу з'єднань.
Ця стаття — збалансована версія цієї історії: що таке edge насправді, де він реально блищить, де абсолютно не підходить, і як я вирішую, який runtime використовувати для кожної частини свого застосунку.
Почнімо з географії. Коли хтось відвідує ваш сайт, запит подорожує від його пристрою через провайдера, через інтернет до вашого сервера, обробляється, і відповідь подорожує весь шлях назад. Якщо ваш сервер знаходиться в us-east-1 (Вірджинія), а ваш користувач — у Токіо, цей маршрут туди-назад покриває приблизно 14 000 км. На швидкості світла через оптоволокно це близько 70 мс лише за фізикою — в один бік. Додайте DNS-резолвінг, TLS-рукостискання та будь-який час обробки, і ви легко дивитесь на 200-400 мс, перш ніж ваш користувач побачить хоча б один байт.
«Edge» означає виконання вашого коду на серверах, розподілених глобально — тих самих CDN-вузлах, що завжди обслуговували статичні ресурси, але тепер вони також можуть виконувати вашу логіку. Замість одного origin-сервера у Вірджинії ваш код працює на 300+ локаціях по всьому світу. Користувач у Токіо потрапляє на сервер у Токіо. Користувач у Парижі потрапляє на сервер у Парижі.
Математика затримки проста і переконлива:
Традиційна архітектура (один origin):
Токіо → Вірджинія: ~140 мс туди-назад (лише фізика)
+ TLS-рукостискання: ще ~140 мс (ще один маршрут)
+ Обробка: 20-50 мс
Разом: ~300-330 мс
Edge (локальний PoP):
Токіо → Edge-вузол у Токіо: ~5 мс туди-назад
+ TLS-рукостискання: ще ~5 мс
+ Обробка: 5-20 мс
Разом: ~15-30 мс
Це покращення у 10-20 разів для початкової відповіді. Це реально, це вимірювано, і для певних операцій це трансформаційно.
Але ось що маркетинг замовчує: edge — це не повне серверне середовище. Це щось фундаментально інше.
Традиційний Node.js працює в повноцінному процесі операційної системи. Він має доступ до файлової системи, може відкривати TCP-з'єднання, може породжувати дочірні процеси, може читати змінні оточення як потік, може робити по суті все, що може робити процес Linux.
Edge-функції не працюють на Node.js. Вони працюють на V8 isolates — тому самому JavaScript-рушії, що живить Chrome, але урізаному до ядра. Уявіть V8 isolate як легкий sandbox:
// Це працює в Node.js, але НЕ на edge
import fs from "fs";
import { createConnection } from "net";
import { execSync } from "child_process";
const file = fs.readFileSync("/etc/hosts"); // ❌ Немає файлової системи
const conn = createConnection({ port: 5432 }); // ❌ Немає сирого TCP
const result = execSync("ls -la"); // ❌ Немає дочірніх процесів
process.env.DATABASE_URL; // ⚠️ Доступно, але статичне, встановлюється при деплоїЩо ви МАЄТЕ на edge — це поверхня Web API, ті самі API, що доступні в браузері:
// Все це працює на edge
const response = await fetch("https://api.example.com/data");
const url = new URL(request.url);
const headers = new Headers({ "Content-Type": "application/json" });
const encoder = new TextEncoder();
const encoded = encoder.encode("hello");
const hash = await crypto.subtle.digest("SHA-256", encoded);
const id = crypto.randomUUID();
// Web Streams API
const stream = new ReadableStream({
start(controller) {
controller.enqueue("chunk 1");
controller.enqueue("chunk 2");
controller.close();
},
});
// Cache API
const cache = caches.default;
await cache.put(request, response.clone());Обмеження реальні та жорсткі:
await fetch() не рахується, але JSON.parse() на 5 МБ payload рахується)Це не Node.js на CDN. Це інший runtime з іншою ментальною моделлю.
Ви, мабуть, чули, що edge-функції мають «нульові cold starts». Це... здебільшого правда, і порівняння дійсно вражає.
Традиційна контейнерна серверless-функція (AWS Lambda, Google Cloud Functions) працює так:
Кроки 2-5 — це cold start. Для Node.js Lambda це зазвичай 200-500 мс. Для Java Lambda — 2-5 секунд. Для .NET Lambda — 500 мс-1,5 с.
V8 isolates працюють інакше:
Кроки 2-3 займають менше 5 мс. Часто менше 1 мс. Ізолят — це не контейнер: немає ОС для завантаження, немає runtime для ініціалізації. V8 створює свіжий ізолят за мікросекунди. Фраза «нульовий cold start» — це маркетингова мова, але реальність (старт менше 5 мс) достатньо близька до нуля, щоб це не мало значення для більшості випадків використання.
Але ось коли cold starts все ж дають про себе знати на edge:
Великі бандли. Якщо ваша edge-функція підтягує 2 МБ залежностей, цей код все одно потрібно завантажити й розпарсити. Я вивчив це на власному досвіді, коли зібрав бібліотеку валідації та бібліотеку форматування дат в edge middleware. Cold start зріс із 2 мс до 40 мс. Все ще швидко, але не «нуль».
Рідкісні локації. Edge-провайдери мають сотні PoP, але не всі PoP тримають ваш код теплим. Якщо ви отримуєте один запит на годину з Найробі, цей ізолят утилізується між запитами. Наступний запит знову платить за старт.
Декілька ізолятів на запит. Якщо ваша edge-функція викликає іншу edge-функцію (або якщо middleware і API-маршрут обидва edge), ви можете розкручувати кілька ізолятів для одного запиту користувача.
Практична порада: тримайте бандли edge-функцій маленькими. Імпортуйте лише те, що потрібно. Агресивно робіть tree-shake. Чим менший бандл, тим швидший cold start, тим більше обіцянка «нульового cold start» виконується.
// ❌ Не робіть так на edge
import dayjs from "dayjs";
import * as yup from "yup";
import lodash from "lodash";
// ✅ Робіть так — використовуйте вбудовані API
const date = new Date().toISOString();
const isValid = typeof input === "string" && input.length < 200;
const unique = [...new Set(items)];Після тривалих експериментів я знайшов чіткий патерн: edge-функції відмінно працюють, коли вам потрібно прийняти швидке рішення щодо запиту до того, як він досягне вашого origin-сервера. Вони — привратники, маршрутизатори та трансформатори, а не серверні застосунки.
Це кіллер-кейс. Запит потрапляє на найближчий edge-вузол, який вже знає, де знаходиться користувач. Не потрібен API-виклик, не потрібна база даних IP-пошуку — платформа надає гео-дані:
// middleware.ts — працює на edge при кожному запиті
import { NextRequest, NextResponse } from "next/server";
export const config = {
matcher: ["/", "/shop/:path*"],
};
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? "US";
const city = request.geo?.city ?? "Unknown";
const region = request.geo?.region ?? "Unknown";
// Редирект на специфічний для країни магазин
if (request.nextUrl.pathname === "/shop") {
const storeMap: Record<string, string> = {
DE: "/shop/eu",
FR: "/shop/eu",
GB: "/shop/uk",
JP: "/shop/jp",
TR: "/shop/tr",
};
const storePath = storeMap[country] ?? "/shop/us";
if (request.nextUrl.pathname !== storePath) {
return NextResponse.redirect(new URL(storePath, request.url));
}
}
// Додати гео-заголовки для подальшого використання
const response = NextResponse.next();
response.headers.set("x-user-country", country);
response.headers.set("x-user-city", city);
response.headers.set("x-user-region", region);
return response;
}Це виконується менш ніж за 5 мс, прямо поруч із користувачем. Альтернатива — надсилання запиту до origin-сервера лише для IP-пошуку та редиректу назад — коштувала б 100-300 мс для користувачів далеко від вашого origin.
Клієнтське A/B тестування спричиняє жахливий «спалах оригінального контенту» — користувач бачить версію A на долю секунди, перш ніж JavaScript замінить на версію B. На edge ви можете призначити варіант до того, як сторінка навіть почне рендеритися:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Перевірити, чи є у користувача вже призначений варіант
const existingVariant = request.cookies.get("ab-variant")?.value;
if (existingVariant) {
// Переписати на правильну сторінку варіанта
const url = request.nextUrl.clone();
url.pathname = `/variants/${existingVariant}${url.pathname}`;
return NextResponse.rewrite(url);
}
// Призначити новий варіант (розподіл 50/50)
const variant = Math.random() < 0.5 ? "control" : "treatment";
const url = request.nextUrl.clone();
url.pathname = `/variants/${variant}${url.pathname}`;
const response = NextResponse.rewrite(url);
response.cookies.set("ab-variant", variant, {
maxAge: 60 * 60 * 24 * 30, // 30 днів
httpOnly: true,
sameSite: "lax",
});
return response;
}Користувач ніколи не бачить мерехтіння, тому що перенаправлення відбувається на рівні мережі. Браузер навіть не знає, що це був A/B тест — він просто отримує сторінку варіанта напряму.
Якщо ваша автентифікація використовує JWT (і ви не робите пошук сесій в базі даних), edge — ідеальне рішення. Верифікація JWT — це чиста криптографія, база даних не потрібна:
import { jwtVerify, importSPKI } from "jose";
import { NextRequest, NextResponse } from "next/server";
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----`;
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
try {
const key = await importSPKI(PUBLIC_KEY, "RS256");
const { payload } = await jwtVerify(token, key, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
});
// Передати інформацію про користувача далі як заголовки
const response = NextResponse.next();
response.headers.set("x-user-id", payload.sub as string);
response.headers.set("x-user-role", payload.role as string);
return response;
} catch {
// Токен прострочений або недійсний
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session-token");
return response;
}
}Цей патерн потужний: edge middleware верифікує токен і передає інформацію про користувача вашому origin як довірені заголовки. Вашим API-маршрутам не потрібно знову верифікувати токен — вони просто читають request.headers.get("x-user-id").
Edge-функції можуть блокувати небажаний трафік ще до того, як він досягне вашого origin:
import { NextRequest, NextResponse } from "next/server";
// Простий rate limiter в пам'яті (для кожної edge-локації)
const rateLimitMap = new Map<string, { count: number; timestamp: number }>();
export function middleware(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",").pop()?.trim()
?? "unknown";
const ua = request.headers.get("user-agent") ?? "";
// Блокувати відомих поганих ботів
const badBots = ["AhrefsBot", "SemrushBot", "MJ12bot", "DotBot"];
if (badBots.some((bot) => ua.includes(bot))) {
return new NextResponse("Forbidden", { status: 403 });
}
// Просте обмеження частоти
const now = Date.now();
const windowMs = 60_000; // 1 хвилина
const maxRequests = 100;
const entry = rateLimitMap.get(ip);
if (entry && now - entry.timestamp < windowMs) {
entry.count++;
if (entry.count > maxRequests) {
return new NextResponse("Too Many Requests", {
status: 429,
headers: { "Retry-After": "60" },
});
}
} else {
rateLimitMap.set(ip, { count: 1, timestamp: now });
}
// Періодичне очищення для запобігання витоку пам'яті
if (rateLimitMap.size > 10_000) {
const cutoff = now - windowMs;
for (const [key, val] of rateLimitMap) {
if (val.timestamp < cutoff) rateLimitMap.delete(key);
}
}
return NextResponse.next();
}Одне застереження: карта обмеження частоти вище — це per-ізолят, per-локація. Якщо у вас 300 edge-локацій, кожна з них має свою карту. Для суворого обмеження потрібне розподілене сховище на кшталт Upstash Redis або Cloudflare Durable Objects. Але для грубого запобігання зловживанням per-локаційні ліміти працюють напрочуд добре.
Edge-функції чудово підходять для трансформації запитів перед тим, як вони досягнуть origin:
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
const url = request.nextUrl;
// Узгодження контенту на основі пристрою
const ua = request.headers.get("user-agent") ?? "";
const isMobile = /mobile|android|iphone/i.test(ua);
response.headers.set("x-device-type", isMobile ? "mobile" : "desktop");
// Прапори функцій з cookie
const flags = request.cookies.get("feature-flags")?.value;
if (flags) {
response.headers.set("x-feature-flags", flags);
}
// Визначення локалі для i18n
const acceptLanguage = request.headers.get("accept-language") ?? "en";
const preferredLocale = acceptLanguage.split(",")[0]?.split("-")[0] ?? "en";
const supportedLocales = [
"en", "tr", "de", "fr", "es", "pt", "ja", "ko", "it",
"nl", "ru", "pl", "uk", "sv", "cs", "ar", "hi", "zh",
];
const locale = supportedLocales.includes(preferredLocale)
? preferredLocale
: "en";
if (!url.pathname.startsWith(`/${locale}`) && !url.pathname.startsWith("/api")) {
return NextResponse.redirect(new URL(`/${locale}${url.pathname}`, request.url));
}
return response;
}Це розділ, який маркетингові сторінки пропускають. Я стикався з кожною з цих стін.
Це головна проблема. Традиційні бази даних (PostgreSQL, MySQL) використовують постійні TCP-з'єднання. Node.js-сервер відкриває пул з'єднань при старті та перевикористовує ці з'єднання між запитами. Ефективно, перевірено, добре зрозуміло.
Edge-функції не можуть цього зробити. Кожен ізолят ефемерний. Немає фази «стартування», де ви відкриваєте з'єднання. Навіть якби ви могли відкрити з'єднання, ізолят може бути утилізований після одного запиту, марнуючи час налаштування з'єднання.
// ❌ Цей патерн фундаментально не працює на edge
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // Пул з 10 з'єднань
});
// Кожне виклик edge буде:
// 1. Створювати новий Pool (не може надійно перевикористовувати між викликами)
// 2. Відкривати TCP-з'єднання до бази даних (яка в us-east-1, не на edge)
// 3. Робити TLS-рукостискання з базою даних
// 4. Виконувати запит
// 5. Відкидати з'єднання, коли ізолят утилізується
// Навіть із сервісами пулінгу з'єднань типу PgBouncer,
// ви все одно платите мережеву затримку від edge → origin бази данихПроблема з round-trip до бази даних фундаментальна. Ваша база даних в одному регіоні. Ваша edge-функція у 300 регіонах. Кожен запит до бази з edge повинен подорожувати від edge-локації до регіону бази даних і назад. Для користувача в Токіо, що потрапляє на edge-вузол у Токіо, але ваша база в Вірджинії:
Edge-функція в Токіо
→ Запит до PostgreSQL у Вірджинії: ~140 мс туди-назад
→ Другий запит: ще ~140 мс
→ Разом: 280 мс лише для двох запитів
Node.js-функція у Вірджинії (той самий регіон, що й БД)
→ Запит до PostgreSQL: ~1 мс туди-назад
→ Другий запит: ще ~1 мс
→ Разом: 2 мс для двох запитів
Edge-функція у 140 разів повільніша для операцій з базою даних у цьому сценарії. Не має значення, що edge-функція стартувала швидше — round-trip до бази даних домінує над усім.
Ось чому існують HTTP-based проксі бази даних (serverless-драйвер Neon, fetch-based драйвер PlanetScale, REST API Supabase). Вони працюють, але все одно роблять HTTP-запити до бази в одному регіоні. Вони вирішують проблему «не можу використовувати TCP», але не проблему «база даних далеко».
// ✅ Це працює на edge (HTTP-based доступ до бази)
// Але все ще повільно, якщо база далеко від edge-вузла
import { neon } from "@neondatabase/serverless";
export const runtime = "edge";
export async function GET(request: Request) {
const sql = neon(process.env.DATABASE_URL!);
// Це робить HTTP-запит до вашої Neon бази
// Працює, але затримка залежить від відстані до регіону бази
const posts = await sql`SELECT * FROM posts WHERE published = true LIMIT 10`;
return Response.json(posts);
}Edge-функції мають обмеження часу CPU, зазвичай 10-50 мс реального часу обчислень. Wall clock time більш щедрий (зазвичай 30 секунд), але CPU-інтенсивні операції швидко досягають ліміту:
// ❌ Це перевищить ліміти часу CPU на edge
export const runtime = "edge";
export async function POST(request: Request) {
const data = await request.json();
// Обробка зображень — CPU-інтенсивна
// (Також не може використовувати sharp, бо це нативний модуль)
const processed = heavyImageProcessing(data.image);
// Генерація PDF — CPU-інтенсивна + потребує Node.js API
const pdf = generatePDF(data.content);
// Велика трансформація даних
const result = data.items // 100 000 елементів
.map(transform)
.filter(validate)
.sort(compare)
.reduce(aggregate, {});
return Response.json(result);
}Якщо вашій функції потрібно більше кількох мілісекунд часу CPU, їй місце на регіональному Node.js-сервері. Крапка.
Цей пункт застає людей зненацька. Дивовижна кількість npm-пакетів залежить від вбудованих модулів Node.js:
// ❌ Ці пакети не працюватимуть на edge
import bcrypt from "bcrypt"; // Нативний C++ binding
import sharp from "sharp"; // Нативний C++ binding
import puppeteer from "puppeteer"; // Потребує filesystem + child_process
import nodemailer from "nodemailer"; // Потребує модуль net
import { readFile } from "fs/promises"; // Node.js filesystem API
import mongoose from "mongoose"; // TCP-з'єднання + Node.js API
// ✅ Edge-сумісні альтернативи
import { hashSync } from "bcryptjs"; // Чиста JS-реалізація (повільніша)
// Для зображень: використовуйте окремий сервіс або API
// Для email: використовуйте HTTP-based email API (Resend, SendGrid REST)
// Для бази даних: використовуйте HTTP-based клієнтиПерш ніж переносити що-небудь на edge, перевірте кожну залежність. Один require("fs"), заритий на три рівні вглиб у вашому дереві залежностей, зламає вашу edge-функцію під час виконання — не під час збірки. Ви задеплоїте, все виглядатиме нормально, а потім перший запит потрапить на той шлях коду і ви отримаєте загадкову помилку.
Edge-платформи мають суворі обмеження розміру бандлу:
Це здається достатнім, поки ви не зробите import бібліотеки UI-компонентів, бібліотеки валідації та бібліотеки дат. Якось мій edge middleware роздувся до 3,5 МБ, тому що я імпортував з barrel-файлу, який підтягнув весь каталог @/components.
// ❌ Barrel-файлові імпорти можуть підтягнути занадто багато
import { validateEmail } from "@/lib/utils";
// Якщо utils.ts реекспортує з 20 інших модулів, всі вони потрапляють у бандл
// ✅ Імпортуйте безпосередньо з джерела
import { validateEmail } from "@/lib/validators/email";Edge-функції вміють робити стрімінгові відповіді (Web Streams API), але довготривалі WebSocket-з'єднання — це інша історія. Хоча деякі платформи підтримують WebSockets на edge (Cloudflare Workers, Deno Deploy), ефемерна природа edge-функцій робить їх поганим вибором для stateful довготривалих з'єднань.
Next.js робить простим вибір edge runtime для кожного маршруту окремо. Вам не потрібно йти all-in — ви вибираєте точно, які маршрути працюють на edge.
Next.js middleware завжди працює на edge. Це за замовчуванням — middleware перехоплює кожен відповідний запит, тому має бути швидким і глобально розподіленим:
// middleware.ts — завжди працює на edge, не потребує вибору
import { NextRequest, NextResponse } from "next/server";
export function middleware(request: NextRequest) {
// Це виконується перед кожним відповідним запитом
// Тримайте це швидким — без викликів бази, без важких обчислень
return NextResponse.next();
}
export const config = {
// Працює лише на конкретних шляхах
matcher: [
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)",
],
};Будь-який route handler може обрати edge runtime:
// app/api/hello/route.ts
export const runtime = "edge"; // Цей один рядок змінює runtime
export async function GET(request: Request) {
return Response.json({
message: "Hello from the edge",
region: process.env.VERCEL_REGION ?? "unknown",
timestamp: Date.now(),
});
}Навіть цілі сторінки можуть рендеритися на edge, хоча я б ретельно подумав перед цим:
// app/dashboard/page.tsx
export const runtime = "edge";
export default async function DashboardPage() {
// Пам'ятайте: тут немає Node.js API
// Будь-яке отримання даних повинно використовувати fetch() або edge-сумісні клієнти
const data = await fetch("https://api.example.com/dashboard", {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
next: { revalidate: 60 },
}).then((r) => r.json());
return (
<main>
<h1>Dashboard</h1>
{/* рендер даних */}
</main>
);
}Ось практичний довідник того, що можна і що не можна використовувати:
// ✅ Доступно на edge
fetch() // HTTP-запити
Request / Response // Стандартний веб request/response
Headers // HTTP-заголовки
URL / URLSearchParams // Парсинг URL
TextEncoder / TextDecoder // Кодування рядків
crypto.subtle // Криптографічні операції (підпис, хешування)
crypto.randomUUID() // Генерація UUID
crypto.getRandomValues() // Криптографічні випадкові числа
structuredClone() // Глибоке клонування
atob() / btoa() // Base64 кодування/декодування
setTimeout() / setInterval() // Таймери (але пам'ятайте про ліміти CPU)
console.log() // Логування
ReadableStream / WritableStream // Стрімінг
AbortController / AbortSignal // Скасування запитів
URLPattern // Зіставлення шаблонів URL
// ❌ НЕ доступно на edge
require() // CommonJS (використовуйте import)
fs / path / os // Вбудовані модулі Node.js
process.exit() // Керування процесом
Buffer // Використовуйте Uint8Array замість цього
__dirname / __filename // Використовуйте import.meta.url
setImmediate() // Не є веб-стандартомЯ хочу заглибитися в автентифікацію, тому що це один із найвпливовіших випадків використання edge, але також легко зробити неправильно.
Патерн, який працює: верифікуйте токен на edge, передавайте довірені claims далі, ніколи не торкайтеся бази даних у middleware.
// lib/edge-auth.ts — Edge-сумісні утиліти автентифікації
import { jwtVerify, SignJWT, importSPKI, importPKCS8 } from "jose";
const PUBLIC_KEY_PEM = process.env.JWT_PUBLIC_KEY!;
const ISSUER = "https://auth.myapp.com";
const AUDIENCE = "https://myapp.com";
export interface TokenPayload {
sub: string;
email: string;
role: "user" | "admin" | "moderator";
iat: number;
exp: number;
}
export async function verifyToken(token: string): Promise<TokenPayload | null> {
try {
const publicKey = await importSPKI(PUBLIC_KEY_PEM, "RS256");
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: ISSUER,
audience: AUDIENCE,
clockTolerance: 30, // 30 секунд толеранції зсуву годинника
});
return payload as unknown as TokenPayload;
} catch {
return null;
}
}
export function isTokenExpiringSoon(payload: TokenPayload): boolean {
const now = Math.floor(Date.now() / 1000);
const fiveMinutes = 5 * 60;
return payload.exp - now < fiveMinutes;
}// middleware.ts — Middleware автентифікації
import { NextRequest, NextResponse } from "next/server";
import { verifyToken, isTokenExpiringSoon } from "./lib/edge-auth";
const PUBLIC_PATHS = ["/", "/login", "/register", "/api/auth/login"];
const ADMIN_PATHS = ["/admin"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Пропустити автентифікацію для публічних шляхів
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith("/api/public"))) {
return NextResponse.next();
}
// Витягти токен
const token = request.cookies.get("auth-token")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Верифікувати токен (чиста криптографія — без виклику бази)
const payload = await verifyToken(token);
if (!payload) {
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("auth-token");
return response;
}
// Контроль доступу на основі ролей
if (ADMIN_PATHS.some((p) => pathname.startsWith(p)) && payload.role !== "admin") {
return NextResponse.redirect(new URL("/unauthorized", request.url));
}
// Передати верифіковану інформацію про користувача origin як довірені заголовки
const response = NextResponse.next();
response.headers.set("x-user-id", payload.sub);
response.headers.set("x-user-email", payload.email);
response.headers.set("x-user-role", payload.role);
// Сигнал, якщо токен потребує оновлення
if (isTokenExpiringSoon(payload)) {
response.headers.set("x-token-refresh", "true");
}
return response;
}// app/api/profile/route.ts — Origin-сервер читає довірені заголовки
export async function GET(request: Request) {
// Ці заголовки були встановлені edge middleware після верифікації JWT
// Їм можна довіряти, бо вони надходять від нашої власної інфраструктури
const userId = request.headers.get("x-user-id");
const userRole = request.headers.get("x-user-role");
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Тепер можемо звернутися до бази — ми на origin-сервері,
// прямо поруч з базою даних, з пулом з'єднань
const user = await db.user.findUnique({ where: { id: userId } });
return Response.json(user);
}Ключовий інсайт: edge робить швидку частину (криптографічна верифікація), а origin робить повільну частину (запити до бази). Кожен працює там, де найефективніший.
Одне важливе застереження: це працює лише для JWT. Якщо ваша система автентифікації потребує пошуку в базі при кожному запиті (як session-based автентифікація з session ID cookie), edge не може допомогти — вам все одно потрібно звертатися до бази, а це означає round-trip до origin-регіону.
Кешування на edge — ось де стає цікаво. Edge-вузли можуть кешувати відповіді, що означає, що подальші запити на той самий URL обслуговуються безпосередньо з edge без звернення до origin взагалі.
// app/api/products/route.ts
export const runtime = "edge";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") ?? "all";
const products = await fetch(
`${process.env.ORIGIN_API}/products?category=${category}`,
).then((r) => r.json());
return Response.json(products, {
headers: {
// Кешувати на CDN 60 секунд
// Віддавати застаріле під час ревалідації до 5 хвилин
// Клієнт може кешувати 10 секунд
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300, max-age=10",
// Vary за цими заголовками, щоб різні варіанти мали різні записи кешу
Vary: "Accept-Language, Accept-Encoding",
// CDN-специфічний тег кешу для цілеспрямованого інвалідування
"Cache-Tag": `products,category-${category}`,
},
});
}Патерн stale-while-revalidate особливо потужний на edge. Ось що відбувається:
Ваші користувачі майже завжди отримують кешовану відповідь. Компроміс свіжості є явним і налаштовуваним.
Edge Config від Vercel (та аналогічні сервіси інших платформ) дозволяє зберігати конфігурацію типу ключ-значення, що реплікується на кожну edge-локацію. Це неймовірно корисно для прапорів функцій, правил редиректів та конфігурації A/B тестів, які ви хочете оновлювати без передеплою:
import { get } from "@vercel/edge-config";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(request: NextRequest) {
// Читання Edge Config надзвичайно швидке (~1 мс), бо
// дані реплікуються на кожну edge-локацію
const maintenanceMode = await get<boolean>("maintenance_mode");
if (maintenanceMode) {
return NextResponse.rewrite(new URL("/maintenance", request.url));
}
// Прапори функцій
const features = await get<Record<string, boolean>>("feature_flags");
if (features?.["new_pricing_page"] && request.nextUrl.pathname === "/pricing") {
return NextResponse.rewrite(new URL("/pricing-v2", request.url));
}
// Динамічні редиректи (оновлюйте без передеплою)
const redirects = await get<Array<{ from: string; to: string; permanent: boolean }>>(
"redirects",
);
if (redirects) {
const match = redirects.find((r) => r.from === request.nextUrl.pathname);
if (match) {
return NextResponse.redirect(
new URL(match.to, request.url),
match.permanent ? 308 : 307,
);
}
}
return NextResponse.next();
}Це справжній game-changer. До Edge Config зміна прапора функції означала зміну коду та передеплой. Тепер ви оновлюєте JSON-значення в дашборді, і воно поширюється глобально за секунди.
Зробімо чесну математику замість маркетингової. Порівняю три архітектури для типового API-ендпоінту, якому потрібно запитати базу даних:
Архітектура A: Традиційний регіональний Node.js
Користувач у Токіо → Origin у Вірджинії: 140 мс
+ Запит до БД 1 (той самий регіон): 2 мс
+ Запит до БД 2 (той самий регіон): 2 мс
+ Обробка: 5 мс
= Разом: ~149 мс
Архітектура B: Edge-функція з HTTP-базою
Користувач у Токіо → Edge у Токіо: 5 мс
+ Запит до БД 1 (HTTP до Вірджинії): 145 мс
+ Запит до БД 2 (HTTP до Вірджинії): 145 мс
+ Обробка: 3 мс
= Разом: ~298 мс ← ПОВІЛЬНІШЕ ніж регіональний
Архітектура C: Edge-функція з регіональною базою (read replica)
Користувач у Токіо → Edge у Токіо: 5 мс
+ Запит до БД 1 (HTTP до репліки в Токіо): 8 мс
+ Запит до БД 2 (HTTP до репліки в Токіо): 8 мс
+ Обробка: 3 мс
= Разом: ~24 мс ← Найшвидше, але потребує мультирегіональної БД
Архітектура D: Edge для автентифікації + регіональний для даних
Користувач у Токіо → Edge middleware у Токіо: 5 мс (верифікація JWT)
→ Origin у Вірджинії: 140 мс
+ Запит до БД 1 (той самий регіон): 2 мс
+ Запит до БД 2 (той самий регіон): 2 мс
+ Обробка: 5 мс
= Разом: ~154 мс
(Але автентифікація вже верифікована — origin не потрібно перевіряти знову)
(І неавторизовані запити блокуються на edge — ніколи не досягають origin)
Висновки:
Архітектура D — це те, що я використовую для більшості проєктів. Edge обробляє те, що йому добре вдається (швидкі рішення, автентифікація, маршрутизація), а регіональний Node.js-сервер обробляє те, що вдається йому (запити до бази, важкі обчислення).
Математика повністю перевертається, коли немає бази даних:
Редирект (edge):
Користувач у Токіо → Edge у Токіо → відповідь-редирект: ~5 мс
Редирект (регіональний):
Користувач у Токіо → Origin у Вірджинії → відповідь-редирект: ~280 мс
Статична API-відповідь (edge + кеш):
Користувач у Токіо → Edge у Токіо → кешована відповідь: ~5 мс
Статична API-відповідь (регіональний):
Користувач у Токіо → Origin у Вірджинії → відповідь: ~280 мс
Блокування ботів (edge):
Поганий бот будь-де → Edge (найближчий) → 403 відповідь: ~5 мс
(Бот ніколи не досягає вашого origin-сервера)
Блокування ботів (регіональний):
Поганий бот будь-де → Origin у Вірджинії → 403 відповідь: ~280 мс
(Бот все одно споживав ресурси origin)
Для операцій, що не потребують бази даних, edge у 20-50 разів швидший. Це не маркетинг — це фізика.
Після року роботи з edge-функціями в продакшені, ось діаграма рішень, яку я використовую для кожного нового ендпоінту або частини логіки:
Якщо імпортує fs, net, child_process або будь-який нативний модуль — Node.js регіональний. Без дебатів.
Якщо так, і у вас немає read replica поблизу користувачів — Node.js регіональний (у тому самому регіоні, що й ваша база). Round-trip до бази домінуватиме.
Якщо так, і у вас є глобально розподілені read replica — Edge може працювати, використовуючи HTTP-based клієнти бази даних.
Якщо так — Edge. Це sweet spot. Ви приймаєте швидке рішення, що визначає, що станеться із запитом до того, як він досягне origin.
Якщо так — Edge з правильними заголовками Cache-Control. Навіть якщо перший запит йде до origin, наступні запити обслуговуються з edge-кешу.
Якщо включає значні обчислення (обробка зображень, генерація PDF, великі трансформації даних) — Node.js регіональний.
Якщо це фонове завдання або webhook — Node.js регіональний. Ніхто його не чекає. Якщо це запит від користувача, де важлива кожна мілісекунда — Edge, якщо відповідає іншим критеріям.
// ✅ ІДЕАЛЬНО для edge
// - Middleware (автентифікація, редиректи, переписування, заголовки)
// - Логіка геолокації
// - Призначення A/B тестів
// - Виявлення ботів / правила WAF
// - API-відповіді, дружні до кешу
// - Перевірка прапорів функцій
// - CORS preflight відповіді
// - Статичні трансформації даних (без БД)
// - Верифікація підписів вебхуків
// ❌ ЗАЛИШИТИ на Node.js регіональному
// - CRUD-операції з базою
// - Завантаження / обробка файлів
// - Маніпуляції з зображеннями
// - Генерація PDF
// - Надсилання email (використовуйте HTTP API, але все ж регіональний)
// - WebSocket-сервери
// - Фонові задачі / черги
// - Все, що використовує нативні npm-пакети
// - SSR-сторінки з запитами до бази
// - GraphQL-резолвери, що звертаються до баз
// 🤔 ЗАЛЕЖИТЬ ВІД СИТУАЦІЇ
// - Автентифікація (edge для JWT, регіональний для session-БД)
// - API-маршрути (edge якщо без БД, регіональний якщо з БД)
// - Серверно-рендерені сторінки (edge якщо дані з кешу/fetch, регіональний якщо з БД)
// - Real-time функції (edge для початкової автентифікації, регіональний для постійних з'єднань)Для цього сайту ось розподіл:
Edge (middleware):
Node.js регіональний:
Статичний (без runtime взагалі):
Найкращий runtime — часто відсутність runtime взагалі. Якщо ви можете попередньо рендерити щось при збірці та обслуговувати як статичний ресурс, це завжди буде швидше за будь-яку edge-функцію. Edge — для речей, які справді повинні бути динамічними при кожному запиті.
Edge-функції не є заміною традиційних серверів. Вони — доповнення. Вони — додатковий інструмент у вашому архітектурному набору, який неймовірно потужний для правильних випадків використання та активно шкідливий для неправильних.
Евристика, до якої я постійно повертаюся: якщо вашій функції потрібно звертатися до бази даних в одному регіоні, розміщення функції на edge не допомагає — це шкодить. Ви просто додали ще один хоп. Функція запускається швидше, але потім витрачає 100+ мс на зворотній шлях до бази. Чистий результат: повільніше, ніж виконання всього в одному регіоні.
Але для рішень, які можна прийняти лише на основі інформації в самому запиті — геолокація, cookies, заголовки, JWT — edge неперевершений. Ці 5 мс edge-відповіді — не синтетичні бенчмарки. Вони реальні, і ваші користувачі відчувають різницю.
Не переносьте все на edge. Не тримайте все подалі від edge. Розміщуйте кожну частину логіки там, де фізика на її боці.