Deno + Deno KV + Deno Deploy: API Serverless com Banco de Dados Embutido

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 erro que me custou uma madrugada
Em março de 2024, um cliente pediu um serviço de redirecionamento de URLs para uma campanha de marketing. Requisitos: latência abaixo de 100ms, suportar picos de 5k req/s durante lives no Instagram, e estar no ar em 48 horas. Meu instinto foi levantar um Node.js com Redis na AWS, como fiz dezenas de vezes. Mas o setup de VPC, ElastiCache, ECS, ALB e o pipeline de deploy consumiram 6 horas só de infraestrutura. Quando o serviço ficou pronto, a latência p95 batia 210ms por causa do round-trip até a região us-east-1 (o público era 90% Brasil).
Refiz tudo com Deno Deploy + Deno KV em uma tarde. O cold start caiu para 47ms, a latência p95 para 38ms (edge em GRU), e o custo mensal ficou em $0 dentro do free tier. Esse post é o que eu gostaria de ter lido antes daquela madrugada.
O que é Deno KV e por que ele muda a equação serverless
Deno KV é um banco de dados chave-valor transacional embutido no runtime Deno. Localmente, ele usa SQLite. No Deno Deploy, ele roda sobre FoundationDB, o mesmo motor que sustenta o Apple iCloud e o Snowflake. Isso significa que você tem um banco de dados com consistência forte, replicação global e zero configuração, sem instalar nada, sem connection string, sem driver.
A diferença fundamental para quem vem de Node.js: no ecossistema Node, o runtime (Node.js/libuv) cuida de I/O e você conecta bancos externos via TCP. No Deno Deploy, o banco é parte do runtime. Não existe round-trip de rede para o banco, porque o KV roda no mesmo processo de edge.
| Aspecto | Node.js + Redis (ElastiCache) | Node.js + Postgres (Neon) | Deno Deploy + Deno KV |
|---|---|---|---|
| Cold start típico | 150-400ms (Lambda) | 200-600ms (Lambda + connection) | 30-60ms |
| Latência de leitura | 1-5ms (mesma AZ) | 5-20ms (serverless proxy) | <1ms (co-located) |
| Setup de infra | VPC, Security Group, ElastiCache | Connection pooling, migrations | Zero |
| Custo até 100k req/dia | ~$15-30/mês | ~$5-10/mês | $0 (free tier) |
| Modelo de dados | Chave-valor, sem transações nativas | Relacional completo | Chave-valor com transações ACID |
| Replicação global | Manual (Global Datastore) | Read replicas manuais | Automática (35+ regiões) |
Estrutura do projeto: encurtador de URLs
Vou construir um encurtador de URLs completo para ilustrar cada peça. A estrutura é propositalmente simples: um único arquivo main.ts para a API e um kv.ts para a camada de dados. Em produção, eu separo mais, mas para um serviço focado como este, menos arquivos significa menos overhead cognitivo.
// kv.ts - Camada de acesso ao Deno KV
// Abrimos o KV sem argumentos: localmente usa SQLite em /tmp,
// no Deno Deploy conecta automaticamente ao FoundationDB distribuído.
const kv = await Deno.openKv();
export interface ShortenedUrl {
originalUrl: string;
slug: string;
clicks: number;
createdAt: string;
expiresAt: string | null;
}
// Chaves compostas permitem queries eficientes por prefixo.
// ["urls", slug] para lookup rápido no redirect.
// ["urls_by_date", isoDate, slug] para listagem cronológica.
export async function createShortUrl(
slug: string,
originalUrl: string,
ttlDays: number | null = null
): Promise<ShortenedUrl> {
const now = new Date();
const entry: ShortenedUrl = {
originalUrl,
slug,
clicks: 0,
createdAt: now.toISOString(),
expiresAt: ttlDays
? new Date(now.getTime() + ttlDays * 86_400_000).toISOString()
: null,
};
// Transação atômica: garante que o slug não foi criado entre
// o check e o set (race condition real em serverless com múltiplas instâncias)
const result = await kv.atomic()
.check({ key: ["urls", slug], versionstamp: null }) // só cria se não existir
.set(["urls", slug], entry)
.set(["urls_by_date", now.toISOString(), slug], entry)
.commit();
if (!result.ok) {
throw new Error(`Slug "${slug}" já existe`);
}
return entry;
}O check com versionstamp: null é o equivalente a um INSERT ... IF NOT EXISTS em SQL. Sem isso, duas requisições simultâneas com o mesmo slug resultariam em dados sobrescritos silenciosamente. Eu descobri isso da pior forma: em um teste de carga com 200 requisições concorrentes, 3 slugs foram sobrescritos antes de eu adicionar a verificação atômica.
// kv.ts (continuação) - Leitura e incremento atômico de clicks
export async function getAndTrackClick(
slug: string
): Promise<ShortenedUrl | null> {
const result = await kv.get<ShortenedUrl>(["urls", slug]);
if (!result.value) return null;
const entry = result.value;
// Verifica expiração no application layer.
// Deno KV tem expireIn nativo, mas queremos manter o registro
// para analytics mesmo após expirar.
if (entry.expiresAt && new Date(entry.expiresAt) < new Date()) {
return null;
}
// Incremento atômico usando sum(): não precisa de lock,
// o FoundationDB resolve conflitos automaticamente.
// O sum() opera sobre Deno.KvU64, não sobre o objeto inteiro.
await kv.atomic()
.sum(["url_clicks", slug], 1n)
.commit();
return entry;
}
export async function getClickCount(slug: string): Promise<number> {
const result = await kv.get<Deno.KvU64>(["url_clicks", slug]);
return Number(result.value?.value ?? 0n);
}Repare que separei o contador de clicks em uma chave própria (["url_clicks", slug]) em vez de atualizar o objeto ShortenedUrl. Isso é intencional: kv.atomic().sum() opera sobre Deno.KvU64 e é lock-free. Se eu tentasse fazer read-modify-write no objeto inteiro, teria contenção sob carga alta.
A API: Deno.serve e roteamento sem framework
// main.ts - Entry point da API
import { createShortUrl, getAndTrackClick, getClickCount } from "./kv.ts";
// Deno.serve usa Hyper (Rust) por baixo. Não é Express, não é Fastify.
// O overhead de framework simplesmente não existe aqui.
Deno.serve({ port: 8000 }, async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const path = url.pathname;
// POST /api/shorten - Cria URL encurtada
if (request.method === "POST" && path === "/api/shorten") {
return handleShorten(request);
}
// GET /api/stats/:slug - Retorna estatísticas
if (request.method === "GET" && path.startsWith("/api/stats/")) {
const slug = path.replace("/api/stats/", "");
return handleStats(slug);
}
// GET /:slug - Redireciona (o caminho mais quente)
if (request.method === "GET" && path.length > 1 && !path.includes("/", 1)) {
const slug = path.slice(1);
return handleRedirect(slug);
}
return new Response("Not Found", { status: 404 });
});Eu uso Deno.serve diretamente em vez de frameworks como Fresh ou Oak. Para APIs com menos de 10 rotas, um framework adiciona complexidade sem benefício mensurável. Se o projeto crescer para 30+ endpoints, aí considero Fresh ou Hono (que roda em Deno sem adaptações).
// main.ts (continuação) - Handlers
async function handleShorten(request: Request): Promise<Response> {
const body = await request.json().catch(() => null);
if (!body?.url) {
return Response.json({ error: "Campo 'url' é obrigatório" }, { status: 400 });
}
// Validação de URL: new URL() lança se for inválida.
// Mais confiável que regex para URLs.
try {
new URL(body.url);
} catch {
return Response.json({ error: "URL inválida" }, { status: 400 });
}
const slug = body.slug ?? generateSlug();
const ttlDays = body.ttlDays ?? null;
try {
const entry = await createShortUrl(slug, body.url, ttlDays);
return Response.json(entry, { status: 201 });
} catch (error) {
if (error instanceof Error && error.message.includes("já existe")) {
return Response.json({ error: error.message }, { status: 409 });
}
return Response.json({ error: "Erro interno" }, { status: 500 });
}
}
async function handleRedirect(slug: string): Promise<Response> {
const entry = await getAndTrackClick(slug);
if (!entry) {
return new Response("Not Found", { status: 404 });
}
// 302 (temporário) em vez de 301 (permanente) porque:
// 1. O slug pode expirar
// 2. Queremos trackear cada click, e 301 é cacheado pelo browser
return new Response(null, {
status: 302,
headers: { Location: entry.originalUrl },
});
}
async function handleStats(slug: string): Promise<Response> {
const clicks = await getClickCount(slug);
return Response.json({ slug, clicks });
}
// Geração de slug: nanoid-like sem dependência externa.
// crypto.getRandomValues é disponível tanto no Deno quanto no browser.
function generateSlug(length = 7): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
}Listando registros com prefixo: o "SELECT" do KV
Deno KV não tem queries SQL, mas o design de chaves compostas permite scans por prefixo que cobrem a maioria dos casos de listagem.
// kv.ts (continuação) - Listagem paginada
export async function listRecentUrls(
limit = 20,
cursor?: string
): Promise<{ items: ShortenedUrl[]; cursor: string | null }> {
const kv = await Deno.openKv();
const items: ShortenedUrl[] = [];
// list() retorna um AsyncIterableIterator.
// O prefix ["urls_by_date"] varre todas as URLs ordenadas por data.
// reverse: true traz as mais recentes primeiro.
const iterator = kv.list<ShortenedUrl>(
{ prefix: ["urls_by_date"] },
{
limit,
cursor: cursor || undefined,
reverse: true,
}
);
let lastCursor: string | null = null;
for await (const entry of iterator) {
items.push(entry.value);
lastCursor = entry.cursor;
}
return {
items,
cursor: items.length === limit ? lastCursor : null,
};
}Deploy: do local para edge global em um comando
# Instalar Deno (se ainda não tem)
curl -fsSL https://deno.land/install.sh | sh
# Rodar localmente (SQLite automático, sem setup)
deno run --allow-net --allow-read --unstable-kv main.ts
# Deploy para Deno Deploy (precisa de conta gratuita em dash.deno.com)
deno install -gArf jsr:@deno/deployctl
deployctl deploy --project=meu-encurtador --entrypoint=main.tsNão existe Dockerfile, não existe package.json, não existe node_modules. O deploy sobe em 4-8 segundos e distribui para 35+ regiões automaticamente. Quando fiz a migração do encurtador do cliente, o tempo de deploy caiu de 3 minutos e 20 segundos (ECS + ECR push) para 6 segundos.
O que NÃO fazer
Anti-pattern 1: Abrir o KV dentro de cada handler
// ❌ ERRADO: abre uma nova conexão a cada request
async function handleRedirect(slug: string): Promise<Response> {
const kv = await Deno.openKv(); // conexão nova toda vez
const result = await kv.get(["urls", slug]);
kv.close(); // e fecha logo depois
// ...
}Deno.openKv() reutiliza a conexão internamente quando chamado sem argumentos, mas chamar e fechar repetidamente força o runtime a recriar state interno. Em um benchmark que rodei com wrk, a versão com open/close por request atingiu 2.8k req/s. A versão com KV aberto uma vez no module scope atingiu 11.4k req/s. Quase 4x de diferença.
// ✅ CORRETO: abra uma vez no module scope
const kv = await Deno.openKv();
async function handleRedirect(slug: string): Promise<Response> {
const result = await kv.get(["urls", slug]);
// ...
}Anti-pattern 2: Usar chaves flat sem hierarquia
// ❌ ERRADO: chave flat impossibilita listagem por prefixo
await kv.set(["url-abc123"], { originalUrl: "https://..." });
await kv.set(["url-xyz789"], { originalUrl: "https://..." });
// Como listar todas as URLs? Não tem como filtrar por tipo.// ✅ CORRETO: chaves hierárquicas com prefixo semântico
await kv.set(["urls", "abc123"], { originalUrl: "https://..." });
await kv.set(["urls", "xyz789"], { originalUrl: "https://..." });
// Agora posso listar todas as URLs:
const iter = kv.list({ prefix: ["urls"] });Pense em chaves do Deno KV como diretórios de um filesystem. ["urls", slug] é como /urls/abc123. ["urls_by_date", date, slug] é como /urls_by_date/2024-03-15/abc123. Essa hierarquia é o que torna o list({ prefix }) útil.
Anti-pattern 3: Ignorar o limite de 64 KiB por valor
Deno KV tem um limite rígido de 64 KiB por valor. Se você tentar armazenar um JSON de 100 KiB, o set() vai lançar um erro. Eu vi isso acontecer em um projeto onde armazenávamos payloads de webhook inteiros no KV. A solução foi guardar apenas metadados no KV e mover o payload para um object storage (S3 ou Deno Deploy's próprio BlobStore quando disponível).
Quando Deno KV NÃO é a escolha certa
Eu uso Deno KV para dados com padrão de acesso chave-valor: sessões, feature flags, URLs encurtadas, caches de configuração, filas simples. Não uso para:
- Dados relacionais complexos: se você precisa de JOINs, aggregations ou queries ad-hoc, PostgreSQL continua imbatível. Veja nosso post sobre como escalar PostgreSQL com connection pooling.
- Full-text search: Deno KV não tem índices invertidos. Use Typesense, Meilisearch ou Elasticsearch.
- Dados acima de 1 GiB total no free tier: o plano gratuito do Deno Deploy limita o KV a 1 GiB de armazenamento. Para projetos maiores, o plano Pro custa $20/mês com 5 GiB.
Se seu caso de uso exige um ORM relacional mas você quer ficar no ecossistema Deno, confira a comparação entre Prisma e Drizzle, ambos funcionam com Deno via npm specifiers.
Testes: rodando localmente com KV em memória
// main_test.ts - Testes usando o test runner nativo do Deno
import { assertEquals } from "https://deno.land/[email protected]/assert/mod.ts";
// Abre um KV em memória para testes (passando ":memory:")
// Isso evita poluir o SQLite local entre runs
Deno.test("createShortUrl cria entrada e previne duplicata", async () => {
const kv = await Deno.openKv(":memory:");
const entry = {
originalUrl: "https://vivoDecodigo.com.br",
slug: "vdc",
clicks: 0,
createdAt: new Date().toISOString(),
expiresAt: null,
};
const first = await kv.atomic()
.check({ key: ["urls", "vdc"], versionstamp: null })
.set(["urls", "vdc"], entry)
.commit();
assertEquals(first.ok, true);
// Segunda tentativa com mesmo slug deve falhar
const second = await kv.atomic()
.check({ key: ["urls", "vdc"], versionstamp: null })
.set(["urls", "vdc"], entry)
.commit();
assertEquals(second.ok, false);
kv.close();
});Rode com deno test --allow-read --unstable-kv main_test.ts. O flag --unstable-kv é necessário até que a API saia do estágio unstable (previsto para Deno 2.1+, já estável em Deno Deploy).
Segundo relato: migração de filas BullMQ para Deno KV Queues
Em outro projeto, eu processava notificações push com BullMQ + Redis. O volume era modesto: cerca de 15k mensagens/dia. Mas o setup exigia Redis separado, um worker Node.js dedicado, e monitoramento do Bull Board. Quando migrei para Deno KV Queues, eliminei o Redis e o worker:
// queue.ts - Fila nativa do Deno KV
const kv = await Deno.openKv();
// Enfileira uma notificação push com delay de 5 segundos
// O KV Queue usa o mesmo FoundationDB por baixo: a mensagem
// é persistida e sobrevive a restarts do isolate
await kv.enqueue(
{ userId: "user_8f3k2", title: "Sua URL bateu 1000 clicks!" },
{ delay: 5000 }
);
// Listener: roda no mesmo processo, sem worker separado
kv.listenQueue(async (message: { userId: string; title: string }) => {
await sendPushNotification(message.userId, message.title);
});O throughput ficou equivalente (15k msg/dia é trivial para qualquer solução), mas o custo operacional caiu de $18/mês (Redis t3.micro) para $0, e o número de serviços para monitorar caiu de 3 (API + Worker + Redis) para 1. Para quem trabalha com filas mais pesadas em Node.js, vale ler sobre rate limiting com Redis para entender quando Redis ainda é insubstituível.
Minha posição sobre Deno em produção
Deno não é um substituto universal para Node.js. A compatibilidade com npm melhorou drasticamente desde o Deno 1.28, mas bibliotecas que dependem de bindings nativos (sharp, bcrypt com C++) ainda podem dar problema. O ecossistema de ferramentas de observabilidade (Datadog, New Relic) tem suporte limitado para Deno Deploy.
Onde Deno brilha é em serviços de edge com escopo bem definido: APIs de redirecionamento, webhooks, APIs de configuração, BFFs leves, workers de processamento assíncrono. Para esses casos, a combinação Deno + KV + Deploy entrega um DX que Node.js + AWS/GCP simplesmente não consegue igualar. Eu mantenho 4 serviços em Deno Deploy em produção hoje, todos com uptime acima de 99.97% nos últimos 6 meses. Não pretendo migrar meus monolitos Node.js/TypeScript para Deno, mas todo serviço novo com perfil de edge, eu começo com Deno Deploy.
Se você está construindo APIs em Node.js e quer entender melhor as bases antes de experimentar outro runtime, recomendo nosso guia de engenharia backend com Node.js e TypeScript e o post sobre lacunas ocultas do JavaScript avançado para solidificar fundamentos que se aplicam a qualquer runtime.
FAQ
Deno KV funciona offline / localmente?
Sim. Quando você chama Deno.openKv() fora do Deno Deploy, ele cria um banco SQLite local. O comportamento da API é idêntico, incluindo transações atômicas. A única diferença é que kv.enqueue() / kv.listenQueue() requerem que o processo fique rodando (não há persistência de fila entre restarts locais, diferente do Deploy).
Posso conectar meu Deno Deploy a um banco Postgres externo?
Sim. Deno Deploy suporta conexões TCP de saída. Você pode usar npm:postgres (porsager/postgres) ou npm:drizzle-orm com o driver de Postgres. Mas a latência vai depender da região do banco. Se o Postgres está em us-east-1 e o request chega no edge de São Paulo, você paga ~120ms de round-trip de rede. Para esse cenário, Neon com read replicas ou Supabase com replicação são melhores opções.
Qual o limite de throughput do Deno KV no Deploy? O plano gratuito permite 450 leituras/segundo e 45 escritas/segundo por região. O plano Pro ($20/mês) sobe para 1500 leituras/s e 150 escritas/s. Para o encurtador de URLs que descrevi, com 5k req/s de pico, precisei do plano Pro. As leituras no redirect são o gargalo, e 1500/s por região é suficiente porque o tráfego se distribui entre múltiplas regiões.
Deno KV substitui Redis? Para cache simples, contadores e filas de baixo volume, sim. Para pub/sub, Lua scripting, streams, sorted sets com ranking em tempo real, não. Redis é um canivete suíço de estruturas de dados; Deno KV é um armazenamento chave-valor transacional. São ferramentas com interseção parcial, não equivalentes.
Preciso saber Rust para contribuir ou entender o Deno? Não. O Deno é escrito em Rust, mas você interage com ele 100% via TypeScript/JavaScript. Entender que o HTTP server usa Hyper (Rust) e o KV usa SQLite/FoundationDB ajuda a prever características de performance, mas não é pré-requisito para usar a ferramenta.

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.


