Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
13 minutos de leitura
Ilustração 3D de elefante Postgres em vidro translúcido com conexões luminosas verdes convergindo em proxy serverless
Ouça este artigo
0:00Postgres 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érioNeonSupabasePlanetScale
EnginePostgres (fork com separação storage/compute)Postgres (RDS-like, gerenciado)MySQL (Vitess)
Protocolo serverlessHTTP nativo + WebSocketPrecisa de connection pooler externo (PgBouncer embutido)HTTP nativo
Branching de bancoSim, copy-on-write instantâneoNão nativoSim
Cold start do banco~500ms (auto-suspend/resume)Sempre ligado (ou paga por pausa)Sempre ligado
Free tier0.5 GB storage, compute gratuito com auto-suspend500 MB, projeto sempre ativoDescontinuou free tier em 2024
Compatibilidade SQLPostgres completo (extensões, CTEs, JSON operators)Postgres completoMySQL 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:

Bash
# 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=require

Guarde essa string como variável de ambiente. Nunca no código.

Bash
# .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:

  1. 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.

  2. 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.

JAVASCRIPT
// 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: "..." }]
JAVASCRIPT
// 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:

SQL
-- 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):

JAVASCRIPT
// 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:

JAVASCRIPT
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 identificador

Para 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:

JAVASCRIPT
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

JAVASCRIPT
// 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);
}
JAVASCRIPT
// 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

JAVASCRIPT
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

JAVASCRIPT
// 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éns

A 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:

JAVASCRIPT
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:

  1. Manter o compute ativo (plano pago do Neon, a partir de ~$19/mês).
  2. Usar um cron que faz um SELECT 1 a 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.

Bash
# 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 pg ou postgres (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.

Marcos Soares

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.