Edge Functions: что это, когда использовать и когда не стоит
Edge runtime, V8 isolates, миф о холодном старте, гео-маршрутизация, A/B-тестирование, аутентификация на edge и почему я перенёс часть вещей обратно на Node.js. Взвешенный взгляд на edge computing.
Слово «edge» звучит повсюду. Vercel об этом говорит. Cloudflare об этом говорит. Deno об этом говорит. Каждый доклад на конференции о веб-производительности неизбежно упоминает «работу на edge», как будто это магическое заклинание, которое делает приложение быстрым.
Я в это поверил. Я перенёс middleware, API-маршруты и даже часть логики рендеринга на edge runtime. Некоторые из этих решений были блестящими. Другие я тихо вернул обратно на Node.js через три недели, после отладки ошибок пула соединений в два часа ночи.
Этот пост — взвешенная версия той истории: что такое edge на самом деле, где он действительно блестит, где категорически не подходит, и как я решаю, какой runtime использовать для каждого элемента приложения.
Что такое Edge?#
Начнём с географии. Когда кто-то посещает ваш сайт, его запрос идёт от устройства через провайдера, через интернет к серверу, обрабатывается, и ответ проделывает весь путь обратно. Если ваш сервер находится в us-east-1 (Вирджиния), а пользователь в Токио, этот round trip покрывает примерно 14 000 км. При скорости света через оптоволокно это около 70 мс только на физику — в одну сторону. Добавьте DNS-резолвинг, TLS-хэндшейк и время обработки, и вы легко получите 200–400 мс до того, как пользователь увидит хоть один байт.
«Edge» означает запуск вашего кода на серверах, распределённых по всему миру — тех же CDN-нодах, которые всегда раздавали статические ресурсы, но теперь они также могут выполнять вашу логику. Вместо одного origin-сервера в Вирджинии ваш код работает в 300+ локациях по всему миру. Пользователь в Токио попадает на сервер в Токио. Пользователь в Париже попадает на сервер в Париже.
Математика задержки проста и убедительна:
Традиционный подход (один origin):
Токио → Вирджиния: ~140 мс round trip (только физика)
+ TLS-хэндшейк: ~140 мс ещё (ещё один round trip)
+ Обработка: 20–50 мс
Итого: ~300–330 мс
Edge (локальный PoP):
Токио → Edge-нода в Токио: ~5 мс round trip
+ TLS-хэндшейк: ~5 мс ещё
+ Обработка: 5–20 мс
Итого: ~15–30 мс
Это улучшение в 10–20 раз для начального ответа. Оно реальное, измеримое, и для определённых операций — трансформационное.
Но вот что маркетинг замалчивает: edge — это не полноценная серверная среда. Это нечто принципиально иное.
V8 Isolates против Node.js#
Традиционный Node.js работает в полноценном процессе операционной системы. У него есть доступ к файловой системе, он может открывать TCP-соединения, создавать дочерние процессы, читать переменные окружения как поток, делать по сути всё, что может делать Linux-процесс.
Edge-функции не работают на Node.js. Они работают на V8 isolates — том же JavaScript-движке, который питает Chrome, но урезанном до минимума. Представьте V8 isolate как легковесную песочницу:
// Это работает в 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());Ограничения реальные и жёсткие:
- Память: 128 МБ на isolate (Cloudflare Workers), 256 МБ на некоторых платформах
- Время CPU: 10–50 мс реального времени CPU (не wall clock time —
await fetch()не считается, ноJSON.parse()для 5 МБ payload считается) - Нет нативных модулей: Всё, что требует C++ binding (bcrypt, sharp, canvas), исключено
- Нет постоянных соединений: Нельзя держать открытое соединение с базой данных между запросами
- Лимиты размера бандла: Обычно 1–5 МБ на весь скрипт worker'а
Это не Node.js на CDN. Это другой runtime с другой ментальной моделью.
Холодный старт: мифы и реальность#
Вы наверняка слышали, что у edge-функций «нулевой холодный старт». Это... в основном правда, и сравнение действительно впечатляет.
Традиционная серверless-функция на основе контейнеров (AWS Lambda, Google Cloud Functions) работает так:
- Приходит запрос
- Платформа выделяет контейнер (если нет свободного)
- Контейнер загружает ОС
- Инициализируется runtime (Node.js, Python и т.д.)
- Загружается и инициализируется ваш код
- Обрабатывается запрос
Шаги 2–5 — это холодный старт. Для Node.js Lambda это обычно 200–500 мс. Для Java Lambda — 2–5 секунд. Для .NET Lambda — 500 мс–1,5 с.
V8 isolates работают иначе:
- Приходит запрос
- Платформа создаёт новый V8 isolate (или переиспользует тёплый)
- Ваш код загружается (он уже скомпилирован в байткод при деплое)
- Обрабатывается запрос
Шаги 2–3 занимают меньше 5 мс. Часто меньше 1 мс. Isolate — это не контейнер: нет ОС для загрузки, нет runtime для инициализации. V8 создаёт свежий isolate за микросекунды. Фраза «нулевой холодный старт» — маркетинговая, но реальность (менее 5 мс на старт) достаточно близка к нулю, чтобы для большинства сценариев это не имело значения.
Но вот когда холодные старты всё же кусают на edge:
Большие бандлы. Если ваша edge-функция тянет 2 МБ зависимостей, этот код всё равно нужно загрузить и распарсить. Я усвоил это на собственном опыте, когда забандлил библиотеку валидации и библиотеку форматирования дат в edge middleware. Холодный старт вырос с 2 мс до 40 мс. Всё ещё быстро, но не «ноль».
Редкие локации. У edge-провайдеров сотни PoP, но не все PoP держат ваш код тёплым. Если вы получаете один запрос в час из Найроби, этот isolate утилизируется между запросами. Следующий запрос снова платит стоимость запуска.
Несколько isolate на один запрос. Если ваша edge-функция вызывает другую edge-функцию (или если middleware и API-маршрут оба edge), вы можете раскручивать несколько isolate на один пользовательский запрос.
Практический совет: держите бандлы edge-функций маленькими. Импортируйте только необходимое. Агрессивно tree-shake'те. Чем меньше бандл, тем быстрее холодный старт, тем больше обещание «нулевого холодного старта» выполняется.
// ❌ Так не делайте на 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 Functions#
После обширных экспериментов я нашёл чёткий паттерн: edge-функции превосходны, когда нужно быстро принять решение о запросе до того, как он достигнет вашего origin-сервера. Они — привратники, маршрутизаторы и трансформаторы, а не серверы приложений.
1. Редиректы на основе геолокации#
Это убийственный сценарий. Запрос попадает на ближайшую 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.
2. A/B-тестирование без мерцания на клиенте#
Клиентское 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) {
// Переписываем URL на правильную страницу варианта
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;
}Пользователь никогда не видит мерцания, потому что rewrite происходит на сетевом уровне. Браузер даже не знает, что это был A/B-тест — он просто получает страницу варианта напрямую.
3. Верификация Auth-токенов#
Если ваша аутентификация использует 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").
4. Обнаружение ботов и ограничение частоты запросов#
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();
}Одна оговорка: map для rate limit выше — это per-isolate, per-location. Если у вас 300 edge-локаций, у каждой свой map. Для строгого ограничения частоты нужно распределённое хранилище вроде Upstash Redis или Cloudflare Durable Objects. Но для грубого предотвращения злоупотреблений per-location лимиты работают на удивление хорошо.
5. Перезапись запросов и заголовки персонализации#
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");
// Feature-флаги из 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;
}Где Edge проваливается#
Это раздел, который маркетинговые страницы пропускают. Я натыкался на каждую из этих стен.
1. Подключения к базе данных#
Это главная проблема. Традиционные базы данных (PostgreSQL, MySQL) используют постоянные TCP-соединения. Node.js-сервер открывает пул соединений при старте и переиспользует эти соединения между запросами. Эффективно, проверено, понятно.
Edge-функции так не могут. Каждый isolate эфемерен. Нет фазы «запуска», где вы открываете соединения. Даже если бы вы могли открыть соединение, isolate может быть утилизирован после одного запроса, растрачивая время установки соединения.
// ❌ Этот паттерн принципиально не работает на 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. Выбрасывать соединение при утилизации isolate
//
// Даже с сервисами пулинга соединений вроде PgBouncer,
// вы всё равно платите сетевую задержку от edge → origin базы данныхПроблема round trip к базе данных фундаментальна. Ваша база данных в одном регионе. Ваша edge-функция в 300 регионах. Каждый запрос к базе данных с edge должен путешествовать от edge-локации к региону базы данных и обратно. Для пользователя в Токио, попадающего на edge-ноду в Токио, но с базой данных в Вирджинии:
Edge-функция в Токио
→ Запрос к PostgreSQL в Вирджинии: ~140 мс round trip
→ Второй запрос: ~140 мс ещё
→ Итого: 280 мс только на два запроса
Node.js-функция в Вирджинии (тот же регион, что и БД)
→ Запрос к PostgreSQL: ~1 мс round trip
→ Второй запрос: ~1 мс ещё
→ Итого: 2 мс на два запроса
Edge-функция в 140 раз медленнее для операций с базой данных в этом сценарии. Неважно, что edge-функция стартовала быстрее — round trip'ы к базе данных доминируют над всем.
Вот почему существуют HTTP-основанные прокси для баз данных (серверless-драйвер Neon, fetch-based драйвер PlanetScale, REST API Supabase). Они работают, но всё равно делают HTTP-запросы к базе данных в одном регионе. Они решают проблему «нельзя использовать TCP», но не проблему «база данных далеко».
// ✅ Это работает на edge (HTTP-доступ к базе данных)
// Но всё равно медленно, если база данных далеко от 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);
}2. Длительные задачи#
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-интенсивная + нужны API Node.js
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-сервере. Точка.
3. Зависимости только для Node.js#
Это ловит людей врасплох. Удивительное количество npm-пакетов зависит от встроенных модулей Node.js:
// ❌ Эти пакеты не будут работать на edge
import bcrypt from "bcrypt"; // Нативный C++ binding
import sharp from "sharp"; // Нативный C++ binding
import puppeteer from "puppeteer"; // Нужна файловая система + child_process
import nodemailer from "nodemailer"; // Нужен модуль net
import { readFile } from "fs/promises"; // API файловой системы Node.js
import mongoose from "mongoose"; // TCP-соединения + API Node.js
// ✅ Edge-совместимые альтернативы
import { hashSync } from "bcryptjs"; // Чистая JS-реализация (медленнее)
// Для изображений: используйте отдельный сервис или API
// Для email: используйте HTTP-based email API (Resend, SendGrid REST)
// Для базы данных: используйте HTTP-based клиентыПрежде чем переносить что-либо на edge, проверьте каждую зависимость. Один require("fs"), зарытый на три уровня глубже в вашем дереве зависимостей, обрушит вашу edge-функцию в runtime — не в build time. Вы задеплоите, всё выглядит нормально, потом первый запрос попадает на этот путь кода и вы получаете загадочную ошибку.
4. Большие размеры бандлов#
Edge-платформы имеют строгие лимиты размера бандла:
- Cloudflare Workers: 1 МБ (бесплатно), 5 МБ (платно)
- Vercel Edge Functions: 4 МБ (сжатый)
- Deno Deploy: 20 МБ
Это звучит как достаточно, пока вы не сделаете import библиотеки UI-компонентов, библиотеки валидации и библиотеки дат. У меня однажды edge middleware раздулась до 3,5 МБ, потому что я импортировал из barrel-файла, который тянул весь каталог @/components.
// ❌ Импорт из barrel-файлов может тянуть слишком много
import { validateEmail } from "@/lib/utils";
// Если utils.ts реэкспортирует из 20 других модулей, все они бандлятся
// ✅ Импортируйте напрямую из источника
import { validateEmail } from "@/lib/validators/email";5. Streaming и WebSocket'ы#
Edge-функции могут делать потоковые ответы (Web Streams API), но долгоживущие WebSocket-соединения — другая история. Хотя некоторые платформы поддерживают WebSocket'ы на edge (Cloudflare Workers, Deno Deploy), эфемерная природа edge-функций делает их плохим выбором для stateful долгоживущих соединений.
Edge Runtime в Next.js#
Next.js позволяет легко переключиться на edge runtime для каждого маршрута отдельно. Не нужно идти all-in — вы выбираете ровно те маршруты, которые работают на edge.
Middleware (всегда Edge)#
Middleware в Next.js всегда работает на edge. Это by design — middleware перехватывает каждый подходящий запрос, поэтому она должна быть быстрой и глобально распределённой:
// middleware.ts — всегда работает на edge, не нужен opt-in
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).*)",
],
};API-маршруты на Edge#
Любой обработчик маршрута может переключиться на 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#
Даже целые страницы могут рендериться на edge, хотя я бы хорошо подумал, прежде чем это делать:
// app/dashboard/page.tsx
export const runtime = "edge";
export default async function DashboardPage() {
// Помните: никаких API Node.js здесь
// Любой fetching данных должен использовать 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 Runtime#
Вот практический справочник по тому, что можно и что нельзя использовать:
// ✅ Доступно на edge
fetch() // HTTP-запросы
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-кейсов, но его также легко сделать неправильно.
Паттерн, который работает: верифицируйте токен на 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. Если ваша система аутентификации требует обращения к базе данных при каждом запросе (как сессионная аутентификация с ID сессии в cookie), edge не поможет — вам всё равно нужно обращаться к базе данных, что означает round trip к origin-региону.
Кэширование на Edge#
Кэширование на edge — вот где становится интересно. Edge-ноды могут кэшировать ответы, что означает, что последующие запросы к тому же URL обслуживаются прямо с edge без обращения к вашему origin.
Правильный Cache-Control#
// 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 запрашивает у origin, кэширует ответ, возвращает его
- Запросы в течение 60 секунд: Edge отдаёт из кэша (0 мс задержки к origin)
- Запрос на 61–360 секундах: Edge немедленно отдаёт устаревшую кэшированную версию, но в фоне запрашивает свежую версию у origin
- После 360 секунд: Кэш полностью истёк, следующий запрос идёт к origin
Ваши пользователи почти всегда получают кэшированный ответ. Компромисс свежести явный и настраиваемый.
Edge Config для динамической конфигурации#
Edge Config от Vercel (и аналогичные сервисы от других платформ) позволяет хранить key-value конфигурацию, которая реплицируется на каждую edge-локацию. Это невероятно полезно для feature-флагов, правил редиректов и конфигурации 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));
}
// Feature-флаги
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();
}Это настоящий переворот. Раньше изменение feature-флага означало изменение кода и повторный деплой. Теперь вы обновляете JSON-значение в дашборде, и оно распространяется глобально за секунды.
Настоящая математика производительности#
Давайте сделаем честную математику вместо маркетинговой. Я сравню три архитектуры для типичного API-эндпоинта, которому нужно обращаться к базе данных:
Сценарий: API профиля пользователя (2 запроса к базе данных)#
Архитектура 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-функция с региональной базой данных (реплика чтения)
Пользователь в Токио → 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)
Выводы:
- Edge + origin база данных = часто медленнее, чем просто использование регионального сервера
- Edge + мультирегиональная база данных = самый быстрый, но самый дорогой и сложный
- Edge для гейткипинга + региональный для данных = лучший прагматичный баланс
- Чистый edge (без базы данных) = непревзойдённый для редиректов и проверок аутентификации
Архитектуру D я использую для большинства проектов. Edge обрабатывает то, в чём хорош (быстрые решения, аутентификация, маршрутизация), а региональный Node.js-сервер обрабатывает то, в чём хорош он (запросы к базе данных, тяжёлые вычисления).
Когда Edge действительно выигрывает: операции без базы данных#
Математика полностью переворачивается, когда нет базы данных:
Редирект (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-функциями в продакшене, вот диаграмма принятия решений, которую я использую для каждого нового эндпоинта или фрагмента логики:
Шаг 1: Нужны ли API Node.js?#
Если импортируется fs, net, child_process или любой нативный модуль — региональный Node.js. Без обсуждений.
Шаг 2: Нужны ли запросы к базе данных?#
Если да, и у вас нет реплик чтения рядом с пользователями — региональный Node.js (в том же регионе, что и база данных). Round trip'ы к базе данных будут доминировать.
Если да, и у вас есть глобально распределённые реплики чтения — Edge может работать, используя HTTP-based клиенты баз данных.
Шаг 3: Это решение о запросе (маршрутизация, аутентификация, редирект)?#
Если да — Edge. Это идеальная зона. Вы принимаете быстрое решение, определяющее, что произойдёт с запросом, до того как он достигнет origin.
Шаг 4: Кэшируем ли ответ?#
Если да — Edge с правильными заголовками Cache-Control. Даже если первый запрос идёт к вашему origin, последующие обслуживаются из edge-кэша.
Шаг 5: CPU-интенсивная операция?#
Если включает значительные вычисления (обработка изображений, генерация PDF, большие трансформации данных) — региональный Node.js.
Шаг 6: Насколько критична задержка?#
Если это фоновая задача или webhook — региональный Node.js. Никто не ждёт. Если это пользовательский запрос, где каждая мс важна — Edge, если соответствует другим критериям.
Шпаргалка#
// ✅ ИДЕАЛЬНО для edge
// - Middleware (аутентификация, редиректы, rewrite, заголовки)
// - Логика геолокации
// - Назначение A/B-тестов
// - Обнаружение ботов / WAF-правила
// - Кэшируемые API-ответы
// - Проверки feature-флагов
// - CORS preflight-ответы
// - Статические трансформации данных (без БД)
// - Верификация подписей webhook
// ❌ ДЕРЖИТЕ на региональном Node.js
// - CRUD-операции с базой данных
// - Загрузка / обработка файлов
// - Манипуляции с изображениями
// - Генерация PDF
// - Отправка email (используйте HTTP API, но всё равно региональный)
// - WebSocket-серверы
// - Фоновые задачи / очереди
// - Всё, что использует нативные npm-пакеты
// - SSR-страницы с запросами к базе данных
// - GraphQL-резолверы, обращающиеся к базам данных
// 🤔 ЗАВИСИТ ОТ СИТУАЦИИ
// - Аутентификация (edge для JWT, региональный для сессий в БД)
// - API-маршруты (edge если без БД, региональный если БД)
// - Server-rendered страницы (edge если данные из кэша/fetch, региональный если БД)
// - Real-time функции (edge для начальной аутентификации, региональный для постоянных соединений)Что я реально запускаю на Edge#
Для этого сайта вот разбивка:
Edge (middleware):
- Определение локали и редирект
- Фильтрация ботов
- Заголовки безопасности (CSP, HSTS и т.д.)
- Логирование доступа
- Ограничение частоты (базовое)
Региональный Node.js:
- Рендеринг контента блога (обработка MDX требует API Node.js через Velite)
- API-маршруты, работающие с Redis
- Генерация OG-изображений (нужно больше времени CPU)
- Генерация RSS-ленты
Статика (вообще без runtime):
- Страницы инструментов (pre-rendered при сборке)
- Страницы постов блога (pre-rendered при сборке)
- Все изображения и ресурсы (раздаются через CDN)
Лучший runtime — часто вообще без runtime. Если вы можете пре-рендерить что-то при сборке и раздавать как статический ресурс, это всегда будет быстрее любой edge-функции. Edge — для вещей, которые действительно должны быть динамическими при каждом запросе.
Честное резюме#
Edge-функции — не замена традиционным серверам. Они — дополнение. Они — дополнительный инструмент в вашем архитектурном арсенале, невероятно мощный для правильных сценариев и активно вредный для неправильных.
Эвристика, к которой я постоянно возвращаюсь: если вашей функции нужно обращаться к базе данных в одном регионе, размещение функции на edge не помогает — оно вредит. Вы просто добавили хоп. Функция запускается быстрее, но потом тратит 100+ мс на обращение обратно к базе данных. Итоговый результат: медленнее, чем запустить всё в одном регионе.
Но для решений, которые можно принять только с информацией из самого запроса — геолокация, cookies, заголовки, JWT — edge непревзойдён. Эти 5 мс edge-ответы — не синтетические бенчмарки. Они реальны, и ваши пользователи чувствуют разницу.
Не переносите всё на edge. Не держите всё вне edge. Размещайте каждую часть логики там, где физика на вашей стороне.