Escalando Aplicações HTMX para 500 Mil Usuários com Django: Cache, CDN e Edge Computing na Prática

Published on: 2026-01-09
Post image
pt htmx-em-producao django-htmx escalar-htmx django-cache-avancado fragment-caching-django htmx-caching redis-cache-django cdn-para-django cloudfront-htmx edge-computing-web django-6-cache django-performance otimizacao-django p95-latency-django

Escalar uma aplicação web baseada em HTMX com Django 6.0 para centenas de milhares de usuários exige um olhar diferente do usado em arquiteturas com APIs e muito JavaScript no navegador. O ponto central é que o HTML continua sendo gerado no servidor e retornado em fragmentos, o que simplifica a interface, mas aumenta a responsabilidade do backend em cada interação.

Em um cenário de crescimento acelerado, gargalos comuns aparecem de forma previsível: mais renderizações de templates, mais consultas ao banco, mais tráfego repetido e mais custo de infraestrutura. A solução madura costuma combinar camadas de cache, otimizações de banco, compressão, rede e monitoramento, sem abandonar a simplicidade do modelo “servidor rende, cliente troca fragmentos”.

Por que o HTMX muda o tipo de desafio de escala

HTMX é uma biblioteca que permite atualizar partes de uma página com requisições HTTP, usando atributos HTML como hx-get e hx-trigger. Esse modelo mantém a renderização no servidor, em vez de enviar JSON para o navegador montar a tela. Como consequência, quase toda interação do usuário vira uma requisição ao backend, o que aumenta a pressão sobre CPU, banco e rede. Ao mesmo tempo, as respostas são previsíveis e repetíveis, o que abre espaço para caching muito eficiente.

Outro efeito prático é que o payload tende a ser maior do que JSON, porque HTML carrega estrutura e texto. Mesmo assim, HTML costuma ser altamente comprimível, o que favorece Brotli e Gzip. Além disso, “caching tradicional de API” nem sempre encaixa, porque a resposta é um fragmento HTML com variações por usuário, filtros e estado. O caminho mais eficaz é tratar cada endpoint de fragmento como um ativo que pode ser cacheado com estratégia.

Cache de fragmentos de template como primeira grande alavanca

Em aplicações com HTMX, muitos endpoints retornam somente um pedaço da interface, como cards de estatísticas, listas e notificações. Isso torna o cache de fragmentos especialmente poderoso, porque o mesmo fragmento pode ser reutilizado sem reconsultar o banco e sem rerenderizar o template. A ideia é guardar o HTML final (já renderizado) por um tempo curto, com uma chave de cache que represente a variação relevante. Esse padrão reduz CPU e I/O de banco ao mesmo tempo.

O exemplo a seguir mostra o cache manual do HTML gerado, o que é útil quando o endpoint é muito acessado e o conteúdo tolera alguns minutos de atraso. Ele usa a interface de cache do Django para armazenar a string HTML por 5 minutos. Também demonstra a construção de uma chave por usuário, que evita vazamento de conteúdo entre contas. Em ambientes de produção, esse cache costuma usar Redis como backend.

from django.http import HttpResponse
from django.core.cache import cache
from django.template.loader import render_to_string

def user_dashboard_stats(request):
    chave_cache = f"stats:{request.user.id}:dashboard"

    html_em_cache = cache.get(chave_cache)
    if html_em_cache:
        return HttpResponse(html_em_cache)

    stats = calculate_user_stats(request.user)
    html = render_to_string("fragments/dashboard_stats.html", {"stats": stats})

    cache.set(chave_cache, html, 300)  # 5 minutos
    return HttpResponse(html)

Além do cache manual, o Django oferece cache no próprio template, útil quando apenas parte do HTML é cara de montar. Nesse caso, o servidor ainda renderiza a página, mas “pula” blocos que já estejam no cache. Isso é valioso quando uma página tem áreas com custos diferentes, como um topo quase estático e um corpo mais dinâmico. O bloco abaixo ilustra o uso de cache tags do Django para cachear um trecho por usuário por 300 segundos.

{% load cache %}
{% cache 300 user_stats request.user.id %}

{{ stats.total_orders }}

Total de pedidos

{% endcache %}

Cache de views: quando o endpoint inteiro pode ser reaproveitado

Nem todo fragmento precisa ser atualizado em tempo real, e muitos toleram dados “levemente desatualizados”. O cache de view guarda a resposta completa de um endpoint por um tempo definido, evitando execução repetida da função, consultas ao banco e renderização. Esse padrão funciona muito bem para listagens públicas, menus de categorias e conteúdos de destaque. Em fragmentos personalizados, o cache precisa variar por usuário e por parâmetros de consulta.

O Django fornece o decorator cache_page, e ele atende bem quando a variação é simples e previsível. Para fragmentos públicos, o tempo pode ser mais agressivo, como 15 minutos. Para fragmentos por usuário, um tempo curto e uma chave com prefixo por usuário ajuda a evitar mistura de conteúdo. O exemplo abaixo mostra ambos os casos de forma direta.

from django.shortcuts import render
from django.views.decorators.cache import cache_page

@cache_page(60 * 15)  # 15 minutos
def product_listing_fragment(request):
    products = Product.objects.filter(active=True)[:20]
    return render(request, "fragments/product_list.html", {"products": products})

@cache_page(60 * 2)  # 2 minutos
def notifications_fragment(request):
    notifications = request.user.notifications.unread()[:10]
    return render(request, "fragments/notifications.html", {"notifications": notifications})

Em endpoints HTMX, um cuidado comum é não cachear requisições que mudam estado, como POST, e também considerar variações por querystring. Um decorator customizado permite padronizar isso e evitar erros, como cachear um formulário submetido. O exemplo a seguir monta a chave com path, usuário e parâmetros, e retorna a resposta do cache quando existir. Em produção, armazenar o objeto HttpResponse no cache requer atenção, então a prática mais segura é cachear o corpo e cabeçalhos essenciais quando necessário.

from functools import wraps
from django.core.cache import cache

def htmx_cache(timeout=300, variar_por_usuario=True):
    def decorator(func_view):
        @wraps(func_view)
        def wrapper(request, *args, **kwargs):
            if request.method != "GET":
                return func_view(request, *args, **kwargs)

            partes = [
                "htmx",
                request.path,
                request.user.id if variar_por_usuario else "publico",
                request.GET.urlencode(),
            ]
            chave_cache = ":".join(str(p) for p in partes)

            resposta_em_cache = cache.get(chave_cache)
            if resposta_em_cache:
                return resposta_em_cache

            resposta = func_view(request, *args, **kwargs)
            cache.set(chave_cache, resposta, timeout)
            return resposta
        return wrapper
    return decorator

Redis para sessões e armazenamento de fragmentos

Redis é um banco em memória usado como cache e estrutura de dados, conhecido por latência baixa e alto throughput. Em aplicações com muitos usuários autenticados, armazenar sessão no banco relacional pode gerar contenção e consultas repetitivas. Ao migrar o armazenamento de sessões para Redis, requisições autenticadas deixam de depender tanto do banco principal. Além disso, Redis se encaixa naturalmente como backend de cache do Django.

Uma configuração típica define um cache padrão e, quando necessário, um cache separado para fragmentos. Separar por banco lógico (por exemplo, /0 e /1) ajuda a isolar políticas de expiração e volume. Também é comum configurar um pool de conexões, que reduz custo de abrir conexões repetidamente. O trecho abaixo mostra uma configuração objetiva para cache e sessão usando Redis.

# settings.py
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://redis-cluster:6379/0",
        "OPTIONS": {
            "pool_class": "redis.BlockingConnectionPool",
            "pool_class_kwargs": {
                "max_connections": 50,
                "timeout": 20,
            },
        },
    },
    "fragments": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://redis-cluster:6379/1",
        "TIMEOUT": 300,
    },
}

SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"

Em volumes muito altos, uma estratégia de dois níveis pode reduzir custo e manter desempenho, usando Redis como “cache quente” e disco como “cache frio”. O cache quente atende o mais acessado, e o frio segura o menos acessado por mais tempo com custo menor. A lógica básica tenta primeiro o cache rápido e, caso encontre no lento, promove o item de volta para o rápido. O exemplo abaixo ilustra esse padrão de forma simples e extensível.

from django.core.cache import caches

class CacheEmCamadas:
    def __init__(self):
        self.cache_quente = caches["default"]        # Redis
        self.cache_frio = caches["filesystem"]       # Disco

    def get(self, chave):
        valor = self.cache_quente.get(chave)
        if valor is not None:
            return valor

        valor = self.cache_frio.get(chave)
        if valor is not None:
            self.cache_quente.set(chave, valor, 300)  # promove
        return valor

    def set(self, chave, valor, timeout=300):
        self.cache_quente.set(chave, valor, timeout)
        self.cache_frio.set(chave, valor, timeout * 3)

CDN e cache na borda para fragmentos estáticos

CDN é uma rede de servidores distribuídos que entrega conteúdo mais perto do usuário, reduzindo latência e protegendo o servidor de origem. Em HTMX, parte relevante dos fragmentos pode ser pública e quase estática, como listagens de produtos ativos, navegação por categorias e conteúdo editorial. Quando esses endpoints recebem cabeçalhos de cache corretos, a CDN consegue responder sem consultar o backend. Isso reduz tráfego no origin e melhora tempo de resposta em picos.

O ponto crítico é sinalizar corretamente quais fragmentos são públicos e quais dependem de autenticação. Para públicos, o cabeçalho Cache-Control com public e max-age habilita cache na CDN. Para privados, private evita que conteúdo personalizado seja armazenado em cache compartilhado. Também é importante o cabeçalho Vary, que separa variações como compressão e o marcador HX-Request usado pelo HTMX.

# middleware.py
class HTMXCDNCacheMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)

        if request.method == "GET" and request.path.startswith("/fragments/"):
            if not requires_authentication(request.path):
                response["Cache-Control"] = "public, max-age=300"
                response["Vary"] = "Accept-Encoding, HX-Request"
            else:
                response["Cache-Control"] = "private, max-age=60"

        return response

Em algumas arquiteturas, funções de borda como Edge Functions ou Lambda@Edge podem ajustar respostas sem voltar ao servidor. Um uso possível é inserir pequenos dados personalizados em um fragmento que, no restante, é igual para muitos usuários. Esse tipo de técnica exige cuidado para não expor dados sensíveis e para não quebrar o cache, mantendo a personalização mínima. O exemplo abaixo mostra a ideia de substituir um marcador por um identificador derivado de cookie, representando uma personalização leve.

// Função na borda (exemplo conceitual)
exports.handler = async (event) => {
    const request = event.Records[0].cf.request;
    const response = event.Records[0].cf.response;

    const requisicaoHTMX = Boolean(request.headers["hx-request"]);
    if (requisicaoHTMX) {
        const cookie = request.headers.cookie ? request.headers.cookie[0].value : "";
        const userId = extrairUserIdDoCookie(cookie);

        if (response.body) {
            response.body = response.body.replace("{{USER_ID}}", String(userId));
        }
    }

    return response;
};

function extrairUserIdDoCookie(cookie) {
    const match = cookie.match(/user_id=(\d+)/);
    return match ? Number(match[1]) : 0;
}

Otimização de consultas: reduzir custo por requisição HTMX

Como HTMX gera muitas requisições pequenas, o custo por requisição precisa ser baixo e consistente. Um problema típico é o N+1, quando a aplicação busca uma lista e depois faz consultas extras para cada item relacionado. No Django, select_related carrega relações “um-para-um” e “muitos-para-um” via JOIN, e prefetch_related faz consultas adicionais planejadas para relações “um-para-muitos”. Com isso, a mesma tela consome menos queries e menos tempo no banco.

Também é útil limitar colunas com only quando o template não precisa do modelo inteiro, reduzindo transferência e deserialização. Esse cuidado ganha importância em feeds, atividades e listas com vários relacionamentos. O exemplo abaixo mostra um feed de atividades com relacionamento de ator, perfil e comentários. A consulta fica mais previsível e evita consultas repetidas durante o render do template.

from django.shortcuts import render

def dashboard_activity_feed(request):
    activities = (
        Activity.objects
        .filter(user=request.user)
        .select_related("actor", "target_content_type")
        .prefetch_related("actor__profile", "comments")
        .only("id", "verb", "timestamp", "actor__username")
        [:20]
    )
    return render(request, "fragments/activity_feed.html", {"activities": activities})

Para estatísticas agregadas, uma técnica eficiente é usar materialized view, ou visão materializada, que pré-calcula resultados e armazena como uma tabela física. Isso reduz custo de consultas pesadas repetidas, especialmente em dashboards. A visão pode ser atualizada periodicamente com REFRESH, equilibrando frescor e performance. O exemplo abaixo cria uma visão por usuário e adiciona índice para acesso rápido.

CREATE MATERIALIZED VIEW user_dashboard_stats AS
SELECT
    user_id,
    COUNT(DISTINCT order_id) AS total_orders,
    SUM(order_total) AS lifetime_value,
    AVG(order_total) AS average_order
FROM orders
WHERE created_at > NOW() - INTERVAL '90 days'
GROUP BY user_id;

CREATE INDEX idx_dashboard_stats_user ON user_dashboard_stats(user_id);

A atualização periódica pode ser feita por um comando de gerenciamento do Django, executado por agendador. A opção CONCURRENTLY reduz bloqueios em alguns bancos, permitindo leitura durante o refresh, desde que existam índices e requisitos atendidos. O exemplo abaixo executa o refresh dentro de um cursor do Django. Esse padrão mantém a lógica no código e facilita operar em produção.

from django.core.management.base import BaseCommand
from django.db import connection

class Command(BaseCommand):
    help = "Atualiza a visão materializada de estatísticas do dashboard"

    def handle(self, *args, **options):
        with connection.cursor() as cursor:
            cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY user_dashboard_stats")

Compressão de respostas HTML: menos tráfego e mais velocidade

Fragmentos HTML tendem a comprimir muito bem porque repetem tags, classes e padrões de texto. Brotli é um algoritmo de compressão moderno, geralmente melhor que Gzip em páginas e HTML, com bom custo-benefício em níveis moderados. Ao ativar Brotli, respostas de dezenas de kilobytes podem virar poucos kilobytes, reduzindo tempo de download e custo de banda. Essa camada melhora especialmente conexões móveis e cenários com alta concorrência.

No Django, é comum manter GZipMiddleware como fallback e adicionar middleware de Brotli antes de outros que possam alterar o corpo. Também é útil configurar um tamanho mínimo, evitando compressão em respostas muito pequenas. A qualidade do Brotli deve equilibrar CPU e taxa de compressão, porque valores muito altos aumentam custo no servidor. O exemplo abaixo mostra uma configuração simples e prática.

# settings.py
MIDDLEWARE = [
    "django.middleware.gzip.GZipMiddleware",          # fallback
    "django_brotli.middleware.BrotliMiddleware",      # preferencial
]

BROTLI_MINIMUM_SIZE = 1024
BROTLI_QUALITY = 4

Pooling e persistência de conexões: menos overhead por requisição

Em alta escala, abrir e fechar conexões frequentemente vira um custo significativo. No banco de dados, CONN_MAX_AGE ativa conexões persistentes no Django, reduzindo handshakes e autenticação repetidos. Além disso, políticas de timeout protegem o sistema de consultas lentas e evitam que requisições fiquem presas indefinidamente. Em paralelo, balanceadores com HTTP/2 permitem multiplexar várias requisições no mesmo TCP, reduzindo o custo de rede típico de muitas chamadas HTMX.

Também é importante limitar o número de conexões simultâneas, especialmente quando há múltiplas instâncias da aplicação. Um pool bem dimensionado evita saturar o banco e mantém latências estáveis, mesmo em picos. Timeouts como statement_timeout ajudam a cortar consultas problemáticas antes que virem fila. O trecho abaixo exemplifica persistência, timeout e uma seção conceitual de opções de pool, que pode variar conforme driver e infraestrutura.

# settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "OPTIONS": {
            "connect_timeout": 10,
            "options": "-c statement_timeout=30000",
        },
        "CONN_MAX_AGE": 600,
    }
}

Polling inteligente com backoff: reduzir requisições desnecessárias

Polling é a técnica de consultar o servidor em intervalos fixos para descobrir se há novidades, comum em notificações e feeds. Em HTMX, isso aparece com hx-trigger="every 5s", e em grande escala pode gerar tráfego redundante. Um padrão mais eficiente é o exponential backoff, ou recuo exponencial, que aumenta o intervalo quando não há mudanças e reduz quando volta a haver novidades. Isso preserva responsividade quando existe atividade e economiza quando o sistema está “quieto”.

Uma forma prática de implementar isso é uma extensão do HTMX que, após cada swap, verifica um marcador no HTML indicando ausência de novos dados. Se estiver vazio, o intervalo dobra até um máximo; se houver dados, volta ao intervalo base. Esse tipo de ajuste precisa reprocessar o elemento com htmx.process para aplicar o novo trigger. O exemplo abaixo mostra essa lógica em JavaScript, com nomes e comentários em português.

htmx.defineExtension("smart-polling", {
    onEvent: function (nome, evt) {
        if (nome === "htmx:afterSwap") {
            const alvo = evt.detail.target;
            const vazio = alvo.querySelector('[data-empty="true"]');

            if (vazio) {
                const intervaloAtual = parseInt(alvo.getAttribute("data-poll-interval") || "5000");
                const novoIntervalo = Math.min(intervaloAtual * 2, 60000);

                alvo.setAttribute("data-poll-interval", String(novoIntervalo));
                alvo.setAttribute("hx-trigger", `every ${novoIntervalo}ms`);
                htmx.process(alvo);
            } else {
                alvo.setAttribute("data-poll-interval", "5000");
                alvo.setAttribute("hx-trigger", "every 5s");
                htmx.process(alvo);
            }
        }
    }
});

O HTML do fragmento pode ativar a extensão e manter o estado do intervalo em um atributo de dados. Esse padrão mantém o comportamento no frontend sem exigir lógica complexa de estado no navegador. Além disso, permite que o servidor influencie o resultado ao retornar um marcador como data-empty="true" quando não houver novidades. O exemplo abaixo mostra a estrutura essencial do container de polling.

<div hx-ext="smart-polling"
     hx-get="/fragments/notifications"
     hx-trigger="every 5s"
     data-poll-interval="5000">
</div>

WebSocket para atualizações de alta frequência com Django Channels

Mesmo com backoff, algumas funcionalidades exigem baixa latência constante, como chat, alertas críticos e contadores em tempo real. WebSocket é um protocolo que mantém uma conexão aberta e permite que o servidor envie eventos assim que acontecem, sem o cliente precisar perguntar. Em Django, Django Channels adiciona suporte a comunicação assíncrona e consumidores WebSocket. Um modelo híbrido costuma funcionar bem: polling para o comum e WebSocket para o realmente crítico.

O consumidor abaixo entra em um grupo por usuário e envia HTML renderizado quando uma notificação chega. Essa abordagem mantém a filosofia do HTMX, porque ainda trafega HTML pronto para inserir na página. Para renderizar template em contexto assíncrono, usa-se sync_to_async para chamar código síncrono com segurança. O exemplo ilustra a estrutura principal do consumidor e do envio do fragmento.

from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async
from django.template.loader import render_to_string

class NotificationConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.user_id = self.scope["user"].id
        await self.channel_layer.group_add(
            f"notifications_{self.user_id}",
            self.channel_name
        )
        await self.accept()

    async def notification_update(self, event):
        html = await self.render_notification_fragment(event["notification"])
        await self.send(text_data=html)

    @sync_to_async
    def render_notification_fragment(self, notification):
        return render_to_string("fragments/notification_item.html", {
            "notification": notification
        })

No lado do HTML, a extensão WebSocket do HTMX pode manter um canal aberto e inserir conteúdo recebido em um container. Esse padrão evita criar uma SPA completa e mantém o backend como fonte do HTML final. Também reduz o número de requisições HTTP em momentos de alta atividade. O exemplo abaixo mostra um container simples conectando em um endpoint WebSocket.

<div hx-ext="ws" ws-connect="/ws/notifications/">
    <div id="notifications-list"></div>
</div>

Invalidação de cache e monitoramento: o que sustenta a escala no longo prazo

Com caching agressivo, o desafio mais difícil costuma ser invalidação, ou seja, remover do cache o que ficou desatualizado. Uma abordagem prática é rastrear dependências, registrando quais chaves de cache dependem de quais objetos. Quando um objeto muda, todas as chaves relacionadas são apagadas. Isso evita depender apenas de TTL e reduz o risco de exibir dados incorretos por tempo demais.

O exemplo abaixo registra uma dependência “chave → (modelo, id)” e permite invalidar tudo quando o objeto for salvo. Armazenar o conjunto de chaves dependentes no cache exige serialização compatível com o backend e cuidado com crescimento do conjunto, mas funciona bem para objetos mais acessados. Em Django, sinais como post_save ajudam a disparar invalidadores quando entidades mudam. O trecho ilustra o rastreador e um signal de invalidação.

from django.core.cache import cache
from django.db.models.signals import post_save
from django.dispatch import receiver

class CacheDependencyTracker:
    @staticmethod
    def register_dependency(chave_cache, modelo, object_id):
        chave_deps = f"deps:{modelo}:{object_id}"
        deps = cache.get(chave_deps, set())
        deps.add(chave_cache)
        cache.set(chave_deps, deps, None)  # sem expiração

    @staticmethod
    def invalidate_dependencies(modelo, object_id):
        chave_deps = f"deps:{modelo}:{object_id}"
        deps = cache.get(chave_deps, set())

        for chave in deps:
            cache.delete(chave)

        cache.delete(chave_deps)

@receiver(post_save, sender=Product)
def invalidate_product_cache(sender, instance, **kwargs):
    CacheDependencyTracker.invalidate_dependencies("Product", instance.id)

Monitoramento fecha o ciclo, porque torna visível o que realmente está acontecendo em produção. Métricas úteis incluem taxa de acerto de cache por endpoint, tempo de renderização de fragmentos, contagem de queries por requisição e proporção de tráfego atendida por CDN versus origin. Esses sinais mostram onde o gargalo mudou após cada melhoria, evitando otimização às cegas. Em conjunto, cache bem invalidado e monitorado mantém desempenho consistente mesmo com crescimento de usuários.

Conclusão: uma arquitetura em camadas para escalar HTMX com Django 6.0

Escalar HTMX para tráfego alto não depende de trocar o modelo por uma SPA, mas de aceitar que o servidor vira o centro de performance e precisa ser tratado como tal. Cache de fragmentos e cache de views reduzem renderização e banco, enquanto Redis acelera sessões e armazenamento temporário. Uma CDN bem configurada atende fragmentos públicos na borda e diminui o volume de requisições no origin. Otimizações de banco, compressão e conexões persistentes reduzem o custo unitário de cada interação.

Em funcionalidades de atualização constante, polling com backoff economiza requisições sem perder utilidade, e WebSocket cobre os casos realmente em tempo real com latência menor. Por fim, invalidação e monitoramento sustentam a confiabilidade, evitando que cache vire fonte de bugs e inconsistências. Com essas camadas combinadas, a simplicidade do HTMX permanece, enquanto a infraestrutura evolui para suportar grandes volumes com estabilidade e previsibilidade.