Ir para o conteúdo
Backend

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

Marcos Soares
Atualizado em 
15 minutos de leitura
Ilustracao 3D de obelisco cristalino com banco de dados embutido representando Deno Deploy serverless
Ouça este artigo
0:00Deno + 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.

AspectoNode.js + Redis (ElastiCache)Node.js + Postgres (Neon)Deno Deploy + Deno KV
Cold start típico150-400ms (Lambda)200-600ms (Lambda + connection)30-60ms
Latência de leitura1-5ms (mesma AZ)5-20ms (serverless proxy)<1ms (co-located)
Setup de infraVPC, Security Group, ElastiCacheConnection pooling, migrationsZero
Custo até 100k req/dia~$15-30/mês~$5-10/mês$0 (free tier)
Modelo de dadosChave-valor, sem transações nativasRelacional completoChave-valor com transações ACID
Replicação globalManual (Global Datastore)Read replicas manuaisAutomá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.

TYPESCRIPT
// 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.

TYPESCRIPT
// 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

TYPESCRIPT
// 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).

TYPESCRIPT
// 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.

TYPESCRIPT
// 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

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

Nã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

TYPESCRIPT
// ❌ 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.

TYPESCRIPT
// ✅ 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

TYPESCRIPT
// ❌ 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.
TYPESCRIPT
// ✅ 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

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

TYPESCRIPT
// 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.

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.