Postgres Serverless com Neon e JavaScript: API sem Servidor com Banco Real

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que ninguém te conta sobre Postgres + serverless
Uma função serverless nasce, executa e morre. Um Postgres espera conexões persistentes, com handshake TCP, negociação TLS e autenticação. Cada cold start de uma função abre uma conexão nova. Com 200 invocações simultâneas, são 200 conexões. O max_connections padrão do Postgres é 100.
O resultado: FATAL: too many connections for role. A função retorna 500, o usuário vê erro, e o time descobre que "serverless + Postgres" não é só trocar a string de conexão.
O Neon resolve isso com um proxy WebSocket que multiplexa conexões no lado do servidor, expondo um endpoint HTTP para queries e um driver compatível com o protocolo Postgres. A função serverless não precisa manter socket TCP aberto: ela faz uma requisição HTTP, o proxy do Neon gerencia o pool real contra o Postgres.
Este post mostra como montar essa stack do zero: criar o banco no Neon, conectar via @neondatabase/serverless, executar queries parametrizadas, tratar erros e evitar os anti-patterns que transformam essa combinação em dor de cabeça.
Neon vs. Supabase vs. PlanetScale: quando usar cada um
Antes de escrever código, a decisão de qual banco serverless usar precisa de critérios explícitos.
| Critério | Neon | Supabase | PlanetScale |
|---|---|---|---|
| Engine | Postgres (fork com separação storage/compute) | Postgres (RDS-like, gerenciado) | MySQL (Vitess) |
| Protocolo serverless | HTTP nativo + WebSocket | Precisa de connection pooler externo (PgBouncer embutido) | HTTP nativo |
| Branching de banco | Sim, copy-on-write instantâneo | Não nativo | Sim |
| Cold start do banco | ~500ms (auto-suspend/resume) | Sempre ligado (ou paga por pausa) | Sempre ligado |
| Free tier | 0.5 GB storage, compute gratuito com auto-suspend | 500 MB, projeto sempre ativo | Descontinuou free tier em 2024 |
| Compatibilidade SQL | Postgres completo (extensões, CTEs, JSON operators) | Postgres completo | MySQL com limitações do Vitess (sem FK em alguns casos) |
Se você precisa de Postgres real com extensões (pgvector, PostGIS, pg_trgm), Neon ou Supabase. Se o projeto é serverless com cold starts frequentes e você quer pagar zero em dev/staging, Neon leva vantagem pelo branching e pelo driver HTTP nativo.
Configurando o Neon e obtendo a connection string
Crie um projeto no console do Neon (console.neon.tech). O que interessa é a connection string no formato:
# Formato da connection string do Neon
# O endpoint .neon.tech já aponta para o proxy que gerencia conexões
postgres://[user]:[password]@[endpoint-id].us-east-2.aws.neon.tech/[dbname]?sslmode=requireGuarde essa string como variável de ambiente. Nunca no código.
# .env (local) ou variável no provider serverless (Vercel, Cloudflare, AWS Lambda)
DATABASE_URL="postgres://app_user:[email protected]/mydb?sslmode=require"O driver @neondatabase/serverless: como funciona por baixo
O pacote @neondatabase/serverless não é um wrapper genérico. Ele implementa duas formas de comunicação:
-
HTTP query (
neon()): envia SQL como POST HTTP para o proxy do Neon. Sem socket, sem handshake Postgres. Latência de uma requisição HTTP. Ideal para funções serverless com execução curta. -
WebSocket (
Pool/Client): abre WebSocket para o proxy, que traduz para o protocolo Postgres. Útil quando você precisa de transações multi-statement ou prepared statements.
A regra: use HTTP para queries simples (SELECT, INSERT, UPDATE isolados). Use WebSocket quando precisar de BEGIN/COMMIT explícito.
// instalar: npm install @neondatabase/serverless
import { neon } from "@neondatabase/serverless";
// neon() retorna uma tagged template function
// Cada chamada é uma requisição HTTP independente, sem pool
const sql = neon(process.env.DATABASE_URL);
const users = await sql`SELECT id, email, created_at FROM users WHERE active = true`;
// users é um array de objetos: [{ id: 1, email: "...", created_at: "..." }]// Para transações, use Pool com WebSocket
import { Pool } from "@neondatabase/serverless";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [100, 1]);
await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [100, 2]);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
// Libera a conexão de volta ao pool, não fecha o WebSocket
client.release();
}Criando a tabela e a API: exemplo completo
Vamos montar uma API de produtos. Primeiro, o schema:
-- Execute no console do Neon ou via migration
CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL CHECK (char_length(name) >= 2),
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
category TEXT NOT NULL DEFAULT 'general',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Índice para buscas por categoria, que vai ser a query mais frequente
CREATE INDEX idx_products_category ON products (category);Agora a API. O exemplo usa o padrão de handler serverless compatível com Vercel Edge Functions, mas a lógica de banco funciona em qualquer runtime JavaScript (Cloudflare Workers, AWS Lambda, Deno Deploy):
// api/products.js
// Handler compatível com Vercel Edge Runtime
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
export const config = { runtime: "edge" };
export default async function handler(request) {
const url = new URL(request.url);
if (request.method === "GET") {
return handleList(url.searchParams);
}
if (request.method === "POST") {
return handleCreate(request);
}
return new Response("Method not allowed", { status: 405 });
}
async function handleList(params) {
const category = params.get("category");
const limit = Math.min(parseInt(params.get("limit") || "20", 10), 100);
// Query parametrizada via tagged template: valores são escapados automaticamente
// Isso previne SQL injection sem precisar de sanitização manual
const rows = category
? await sql`SELECT id, name, price_cents, category, created_at
FROM products
WHERE category = ${category}
ORDER BY created_at DESC
LIMIT ${limit}`
: await sql`SELECT id, name, price_cents, category, created_at
FROM products
ORDER BY created_at DESC
LIMIT ${limit}`;
return Response.json({ data: rows, count: rows.length });
}
async function handleCreate(request) {
const body = await request.json();
const { name, price_cents, category } = body;
// Validação antes de tocar no banco: falhar cedo, falhar barato
if (!name || typeof name !== "string" || name.length < 2) {
return Response.json({ error: "name deve ter pelo menos 2 caracteres" }, { status: 400 });
}
if (!Number.isInteger(price_cents) || price_cents <= 0) {
return Response.json({ error: "price_cents deve ser inteiro positivo" }, { status: 400 });
}
const [created] = await sql`
INSERT INTO products (name, price_cents, category)
VALUES (${name}, ${price_cents}, ${category || "general"})
RETURNING id, name, price_cents, category, created_at
`;
return Response.json({ data: created }, { status: 201 });
}Se você está usando Cloudflare Workers, a adaptação é trocar o export para o formato fetch do Workers. A lógica de banco não muda.
Queries parametrizadas: a tagged template não é mágica
O sql retornado por neon() é uma tagged template literal. Quando você escreve sql\SELECT * FROM users WHERE id = $`, o driver separa a string SQL dos valores e envia como query parametrizada ($1, $2`...) no protocolo HTTP do Neon.
Isso significa que interpolação de nomes de tabela ou coluna não funciona:
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
// ISSO NÃO FUNCIONA COMO ESPERADO
const tableName = "products";
const rows = await sql`SELECT * FROM ${tableName}`;
// Gera: SELECT * FROM $1 → erro de sintaxe no Postgres
// O driver trata ${tableName} como valor parametrizado, não como identificadorPara queries dinâmicas onde a tabela ou coluna vem de input, use um query builder como Drizzle ou Kysely, ou construa a string com whitelist explícita:
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
// Whitelist de colunas permitidas para ordenação
const SORTABLE_COLUMNS = new Set(["name", "price_cents", "created_at"]);
function buildProductQuery(sortBy, category) {
// Rejeita qualquer coluna que não está na whitelist
const safeSort = SORTABLE_COLUMNS.has(sortBy) ? sortBy : "created_at";
// Parte dinâmica (nome de coluna) vai direto na string, validada pela whitelist
// Parte de valor (category) vai parametrizada via tagged template
if (category) {
return sql`SELECT id, name, price_cents FROM products
WHERE category = ${category}
ORDER BY ${sql(safeSort)} DESC`;
}
return sql`SELECT id, name, price_cents FROM products
ORDER BY ${sql(safeSort)} DESC`;
}O que NÃO fazer
Anti-pattern 1: criar instância do driver dentro do handler
// ERRADO: cria uma nova instância do driver a cada invocação
// Em serverless isso não causa leak (a função morre), mas adiciona
// overhead de parsing da connection string em cada request
export default async function handler(request) {
const sql = neon(process.env.DATABASE_URL);
const rows = await sql`SELECT * FROM products`;
return Response.json(rows);
}// CORRETO: instância no escopo do módulo
// Em serverless, o módulo pode ser reutilizado entre invocações (warm start)
// A instância fica pronta sem re-parsing
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
export default async function handler(request) {
const rows = await sql`SELECT * FROM products`;
return Response.json(rows);
}Anti-pattern 2: usar Pool sem liberar a conexão
import { Pool } from "@neondatabase/serverless";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// ERRADO: connect() sem release()
// Em serverless o WebSocket eventualmente morre, mas em edge runtimes
// com reuso de instância, as conexões do pool se esgotam
export default async function handler(request) {
const client = await pool.connect();
const result = await client.query("SELECT * FROM products");
// Esqueceu client.release()
return Response.json(result.rows);
}A correção é o padrão try/finally mostrado na seção de transações acima. Sem exceção.
Anti-pattern 3: concatenar valores na query
// ERRADO: SQL injection clássico
const category = request.url.searchParams.get("category");
const rows = await sql`SELECT * FROM products WHERE category = '${category}'`;
// Se category = "'; DROP TABLE products; --", parabénsA tagged template já parametriza. Não coloque aspas ao redor de ${}. O driver cuida disso.
Tratamento de erros: o que o Neon retorna quando quebra
O Neon retorna erros HTTP com corpo JSON quando a query falha. O driver @neondatabase/serverless transforma esses erros em exceções JavaScript com propriedades do Postgres:
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
async function safeQuery(request) {
try {
const [product] = await sql`
INSERT INTO products (name, price_cents)
VALUES (${request.name}, ${request.price_cents})
RETURNING *
`;
return { ok: true, data: product };
} catch (err) {
// err.code segue os códigos de erro do Postgres
// "23505" = unique_violation, "23514" = check_violation
if (err.code === "23505") {
return { ok: false, error: "Produto duplicado", status: 409 };
}
if (err.code === "23514") {
return { ok: false, error: "Valor viola constraint do banco", status: 422 };
}
// Erro inesperado: logue o código para diagnóstico, não exponha detalhes ao cliente
console.error(`Postgres error ${err.code}: ${err.message}`);
return { ok: false, error: "Erro interno", status: 500 };
}
}A lista completa de códigos está na documentação oficial do Postgres (seção "Appendix A: Error Codes"). Os mais frequentes em APIs: 23505 (unique violation), 23503 (foreign key violation), 23514 (check violation), 42P01 (undefined table).
Auto-suspend e cold start do banco
O Neon suspende o compute após 5 minutos de inatividade no free tier. O primeiro request depois da suspensão leva ~500ms a mais para acordar o compute. Para APIs com tráfego esporádico (webhooks, ferramentas internas, side projects), isso é aceitável. Para APIs com SLA de latência abaixo de 200ms no p99, duas opções:
- Manter o compute ativo (plano pago do Neon, a partir de ~$19/mês).
- Usar um cron que faz um
SELECT 1a cada 4 minutos para evitar suspensão.
Se o tráfego é constante (mais de 1 request por minuto), o auto-suspend nunca dispara e o cold start do banco não é problema.
Esse trade-off entre custo e latência é similar ao que acontece com funções serverless em geral: o cold start é o preço do scale-to-zero.
Migrações e branching: o workflow que muda o jogo
O branching do Neon cria uma cópia copy-on-write do banco inteiro em segundos. Isso significa que cada pull request pode ter seu próprio banco com dados reais (ou seed), sem provisionar instância nova.
# Via Neon CLI (npm install -g neonctl)
neonctl branches create --project-id <id> --name feature/add-orders --parent main
# Retorna uma connection string específica para esse branch
# Use essa string no preview deployment (Vercel preview, por exemplo)Para migrações, qualquer ferramenta que fale Postgres funciona: Drizzle Kit, Prisma Migrate, dbmate, golang-migrate. O Neon não impõe ferramenta de migration. A connection string é Postgres padrão.
Se você está montando APIs que precisam de resiliência no client HTTP, o mesmo cuidado com retry e timeout se aplica às chamadas ao Neon: o driver HTTP pode falhar por timeout de rede, e um retry com backoff resolve a maioria dos casos transientes.
Quando NÃO usar Neon
Neon não é a resposta para tudo. Cenários onde outra solução faz mais sentido:
- Workloads com escrita pesada e constante (mais de 1000 writes/segundo sustentado): o proxy HTTP adiciona latência por query. Um Postgres gerenciado tradicional (RDS, Cloud SQL) com connection pool local (PgBouncer no sidecar) vai ter throughput maior.
- Aplicações que já rodam em servidor persistente (VPS, container, VM): você não precisa do driver HTTP. Use
pgoupostgres(pacote npm) com pool TCP direto. O benefício do Neon é o proxy serverless; sem serverless, é overhead sem ganho. - Dados que precisam de latência sub-milissegundo: use Redis, DragonflyDB ou Deno KV para cache e dados quentes.
Entender onde cada camada da API se encaixa ajuda a decidir se o banco serverless é a camada certa ou se você precisa de um cache na frente.
FAQ
O Neon é compatível com ORMs como Prisma e Drizzle?
Sim. Prisma suporta Neon via adapter (@prisma/adapter-neon) desde a versão 5.4. Drizzle suporta via drizzle-orm/neon-serverless. Ambos usam o driver @neondatabase/serverless por baixo. A connection string é a mesma.
Posso usar o Neon com AWS Lambda ou precisa ser edge?
Funciona em qualquer runtime JavaScript. AWS Lambda, Vercel Serverless Functions, Cloudflare Workers, Deno Deploy. O driver HTTP não depende de APIs específicas de edge. O modo WebSocket precisa de WebSocket global, que existe em Node.js 21+ e em todos os edge runtimes.
O free tier do Neon aguenta um side project em produção?
Para até ~10k queries por dia com banco de até 500MB, sim. O auto-suspend mantém o custo em zero. O limite real é o compute: 190 horas de compute ativo por mês no free tier. Se o banco fica suspenso a maior parte do tempo, sobra.
Como monitoro queries lentas no Neon?
O dashboard do Neon mostra métricas de compute e storage. Para queries lentas, habilite pg_stat_statements (já disponível como extensão no Neon) e consulte as queries com maior mean_exec_time. Funciona igual a qualquer Postgres.
Preciso de PgBouncer com Neon?
Não. O proxy do Neon já faz connection pooling no lado do servidor. Adicionar PgBouncer na frente do Neon é redundante e pode causar conflitos de protocolo. Use o driver @neondatabase/serverless direto.
A posição que defendo
Postgres serverless com Neon é a melhor opção atual para APIs JavaScript que rodam em funções serverless e precisam de banco relacional real. Não é a melhor opção para todo caso de uso, e a seção "quando não usar" existe por isso. A combinação de driver HTTP nativo, branching copy-on-write e compatibilidade total com o ecossistema Postgres (extensões, ferramentas de migration, ORMs) coloca o Neon numa posição que Supabase e PlanetScale não ocupam simultaneamente. Supabase brilha quando você quer auth + storage + realtime integrados. PlanetScale saiu do jogo para quem precisa de free tier. Neon é banco serverless puro, sem extras, e faz isso melhor que os outros.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


