- Aviso e Introdução
- Como as métricas foram coletadas?
- 2.000 Requisições por Segundo
- 3.000 Requisições por Segundo
- Dev.to não consegue linkar para seções com o mesmo nome 😔
- 5.000 Requisições por Segundo
- Mas elas existem, e são incríveis!
- Considerações Finais
Aviso e Introdução
Este blog é principalmente para diversão e exploração educacional. Os resultados aqui não devem ser a única base de suas decisões técnicas. Não significa que uma linguagem é melhor que a outra, por favor não leia tão seriamente.
Na verdade, não faz muito sentido comparar linguagens tão diferentes.
Legal, com isso dito, vamos nos divertir, comparar algumas métricas e ter um melhor entendimento de como ambas as linguagens lidam com alguns aspectos chave (RAM, CPU, Contagem de File Descriptors Abertos & Contagem de OS Threads) quando sob pressão severa.
Como as métricas foram coletadas?
Se você quer saber como este benchmark foi perfilado, expanda a seção abaixo, caso contrário, você pode pular diretamente para os resultados 🤓.
{% collapsible Nos bastidores %}
Tech Stack
Foi criado usando as seguintes tecnologias:
- 1x EC2 t2.micro (o centro de comando da API)
- 1x EC2 t2.xlarge (a arma lançadora de requisições)
- Um Postgres RDS para persistência de dados
- Vegeta para desencadear carga HTTP
- Golang 1.21.4 e Node.js 21.4.0 para o confronto de API
- Open Tofu para automagicamente subir nossos servidores
Importante: O servidor da API tem apenas um processador de 1-core com 1GB de RAM. Este post mostra a eficiência de ambas as abordagens em um ambiente muito limitado. Você pode conferir o código completo aqui.
Diagrama de Fluxo
Como a comunicação acontece?

Ambos os servidores estão localizados dentro do mesmo VPC na AWS, garantindo latência mínima. No entanto, o RDS, embora situado na mesma Região AWS (sa-east-1), opera em outro VPC, introduzindo uma latência mais realista.
Isso é bom porque, em um cenário do mundo real, haverá latência.
Configuração Manual do RDS
Infelizmente, não consegui configurar o Postgres RDS com OpenTofu, (tosse tosse: skill issue) então tive que criá-lo manualmente na AWS e executar o seguinte script:
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);
TRUNCATE TABLE users;
Ok, com tudo no lugar, é hora do show!
Inicialização do Ambiente
Inicie o ambiente com:
tofu apply -auto-approve
O que ele faz?
- Lança 1 VPC + 2 Subnets
- Inicializa 2 Servidores Ubuntu, executando scripts específicos
- Instala Node + Golang no servidor da API
- Configura Vegeta no servidor Gun
- Faz deploy do código da API e do load-tester
- Gera 2 scripts SSH para conectividade (ssh_connect_api.sh & ssh_connect_gun.sh)
Processo de Monitoramento
Com esta configuração, posso acessar o servidor da API e iniciar a API Node ou Go.
Simultaneamente, inicio o monitor_process.sh para capturar métricas como RAM, CPU, Contagem de Threads & Contagem de File Descriptors e salvar em um arquivo .csv.
Tudo é feito baseado no ID do processo da API em execução.

Parâmetros do Script
- ID do Processo
- O número de requisições por segundo (para nomear o arquivo CSV corretamente)
Uma vez que a API está rodando, obtenho o ID do processo usando console.log(process.pid) no Node ou fmt.Printf("ID: %d", os.Getpid()) no Golang.
Então, posso simplesmente executar:
./monitor_process.sh 2587 2000
Este comando monitora nosso processo, atualizando um arquivo .csv chamado process_stats_2000.csv a cada segundo com dados frescos.
Ok, agora vamos analisar os resultados, comparar ambas as APIs e ver quais aprendizados podemos extrair disso, vamos começar! {% endcollapsible %}
2.000 Requisições por Segundo
Certo, para este primeiro passo, executei este script Vegeta que dispara 2.000 requisições por segundo durante 30s para a API do Servidor.
Isso foi feito dentro do Servidor Gun executando
./metrics.sh 2000
O que produz a seguinte saída:
Starting Vegeta attack for 30s at 2000 requests per second...
Então, combinei os resultados em alguns gráficos bonitos, vamos dar uma olhada neles:
Latência x Segundos
Ao olhar para o gráfico de latência, podemos ver que Golang lutou muito inicialmente, levando ~5s para estabilizar.

Isso pode ter sido uma anomalia única, mas como não vou refazer o teste e as métricas estão todas corretas, vou chamar esta de tiro de sorte para o Node.
Node manteve uma latência consistente durante a maior parte do teste, com picos em 12s e 20s.
Golang, por outro lado, teve alguns problemas estabilizando a latência no início, custando a pole position. No entanto, foi bem depois disso, mantendo a latência em torno de 230ms.
Contagem de File Descriptors
Este é interessante.

No Linux, um novo socket e um File Descriptor (FD) correspondente são criados para cada conexão de servidor recebida. Estes FDs armazenam informações de conexão.
No Ubuntu, o limite soft padrão para file descriptors abertos é 1024.
No entanto, tanto Go quanto Node ignoram o limite soft e sempre usam o limite hard. Isso pode ser verificado acessando o FD /proc/$PID/limits após o processo node/go ter iniciado.
Você pode usar o comando ulimit -n para ver o limite soft do OS de file descriptors abertos da sessão de shell atual.

Ok então, isso significa que o OS não interfere com o número de FDs abertos; a linguagem de programação gerencia isso.
Neste teste, Node manteve um número menor, mas irregular, de FDs abertos enquanto Golang disparou até 8.000, estabilizou e permaneceu consistente até o final.
Contagem de Threads
Node.js não era single-threaded? 🤯

Bem, não.
Por padrão, Node inicia algumas threads:
- 1 Main Thread: Executa código JavaScript e lida com o event loop.
- 4 Worker Threads (padrão libuv thread pool)
- Lida com I/O async bloqueante como queries de DNS lookup, módulo crypto e algumas operações de I/O de arquivo.
- V8 Threads:
- 1 Compiler Thread: Compila JavaScript em código de máquina nativo.
- 1 Profiler Thread: Coleta perfis de performance para otimizações.
- 2 ou mais Garbage Collector Threads: Gerencia alocação de memória e coleta de lixo.
- Additional Internal Threads: Número varia, para várias tarefas de background do Node.js e V8.
Notei que, na inicialização do Processo Node, ele criou 11 OS threads e uma vez que as requisições começaram a chegar, a contagem pulou para 15 OS threads e ficou lá.
Go, por outro lado, manteve 4 OS threads estáveis.
RAM
{% details TL;DR %}
- Node.js:
- Uso de RAM consistentemente baixo, entre 75MB e 120MB.
- Utiliza um Event-Loop para operações de I/O, evitando novas threads.
- Mais sobre o Event Loop do Node: Explorando I/O Assíncrono no Node.js.
- Go:
- Maior uso inicial de RAM, estabilizando em 300MB.
- Gera uma nova goroutine para cada requisição de rede.
- Goroutines são mais leves que OS threads, mas ainda impactam a memória sob carga.
- Insight sobre o scheduler do Runtime Go: Go Runtime Scheduler Talk. {% enddetails %}

{% details Explicação %} Node manteve um uso de RAM menor e mais estável, entre 75MB e 120MB, ao longo do teste.
Enquanto isso, o uso de RAM do Go aumentou nos primeiros segundos até estabilizar em 300MB (quase triplicando o pico do Node).
Esta diferença pode ser explicada devido a como ambas as linguagens lidam com operações assíncronas, como comunicação de I/O com banco de dados.
Node usa uma abordagem de Event-Loop, o que significa que não cria nova thread. Em contraste, Go frequentemente gera uma nova goroutine para cada requisição, o que aumenta o uso de memória. A goroutine é uma thread leve gerenciada pelo Runtime Go.
Mesmo sendo mais leve que uma OS Thread, ainda deixa uma pegada de memória quando sob carga pesada.
Para insights sobre o Event Loop do Node, confira este post do blog que escrevi.
Para entender melhor o scheduler do Runtime Go, por favor assista esta palestra fenomenal - uma das melhores que já assisti. {% enddetails %}
CPU
Node foi capaz de usar menos CPU que Go neste, isso pode ser porque o Runtime Go é mais complexo e requer mais passos/cálculos que o Event Loop do libuv.

Geral
Devo ser honesto: fiquei surpreso com este resultado.
Node ganhou este 🏆.
Ele mostrou:
- Latência p99 superior, respondendo em menos de 1.2s para 99% das requisições, comparado aos 4.31s do Go
- Latência média mais rápida, registrando 147ms versus 459ms do Go, 3.1x mais rápido!
- Latência máxima significativamente menor, atingindo pico de apenas 1.5s contra 6.4s do Go, que foi 4.2 mais lento. (qual é Gopher, você está ficando mal!)

3.000 Requisições por Segundo
Agora vamos refazer o teste, enviar 3.000 requisições/s durante 30s para cada API e ver os resultados.
Latência x Segundos
Enquanto Go foi capaz de manter uma latência realmente estável com apenas dois pequenos picos, Node estava em sérios problemas e mostrou uma latência muito inconsistente ao longo do teste.

Contagem de File Descriptors
Lembra que eu disse que nem Node nem Go respeitam o limite soft dos File Descriptors Abertos e ambas as linguagens gerenciam isso sozinhas?
Aqui está um fato curioso:

Golang foi capaz de processar, lidar e entregar mais requisições, em um tempo menor, usando menos recursos ao definir um limite "hard" de FDs abertos em cada período do teste (baseado em alguma métrica que não tenho certeza qual).
Isso é super legal!
Veja como Go gerenciou seus FDs:
- 8 FDs: Nos primeiros 0-3 segundos
- 1.590 FDs: Entre 4-17 segundos
- 2.225 FDs: Entre 18-31 segundos
Node, por outro lado, não interferiu com os file descriptors abertos como Go fez. Você pode ver isso no gráfico.
Empiricamente, parece que Go está pré-alocando (ou pré-abrindo) File Descriptors em alguma taxa e reutilizando-os ao invés de gerar um para cada conexão no momento em que chegam.
Não tenho certeza exatamente como eles fazem isso, no entanto, sinta-se livre para comentar se você tiver alguma dica 😄
Encontrei algumas boas leituras sobre isso:
- Does net/http have connection pool? - fala sobre o pacote
net/httpdo Go e como ele gerencia conexões. - The Go Netpoller - artigo que explica sobre o Go Netpoller.
Contagem de Threads
Ok, algo digno de nota aconteceu neste teste.

Node: atingiu o pico de 11 para 15 OS Threads conforme as requisições começaram a chegar. Acredito que isso seja devido às operações de DNS Lookup, como brevemente mencionado em este issue.
Go: Aumentou seu jogo de 4 para 5 OS Threads. É o Runtime Scheduler orquestrando o show, Go é inteligente o suficiente para empacotar múltiplas Goroutines em cada OS Thread. Quando fica desajeitado, suavemente inicia uma nova OS thread.
Esta abordagem não é apenas eficiente; é uma masterclass em otimização de recursos, espremendo cada último bit de performance do hardware. É Incrível! 🚀
RAM
Neste momento, você provavelmente notou que a linha do Node.js dura mais que a linha do Go, bem, isso é porque a API levou mais tempo para responder todas as requisições que recebeu.
Isso também impactou o uso de RAM. Lembra que para o primeiro teste o uso de RAM do Node estava bem abaixo do Go?
Esse não é o caso quando você tem toneladas de conexões penduradas no servidor esperando para serem processadas.

CPU
Desta vez, Node requereu muito mais Uso de CPU que isso e foi capaz de manter o uso abaixo de 35% enquanto Node atingiu o pico em 64%.

Geral

🎉 Temos uma luta! 🎉
A disputa está aberta e Golang foi muito superior nesta, vamos olhar os números:
Golang teve:
- Latências Menores
- p99: 738.873ms contra 30.001s, 40 vezes menor que Node.
- Média: 60.454ms versus 7.079s - 118 vezes mais rápido
- Máximo: Go atingiu o pico em 1.33s, enquanto Node alcançou o céu com 30.0004s.
- Taxa de Sucesso Perfeita (100%)
- Contra 91.93% do Node, que teve algumas requisições falhando.
Foi um massacre, foi como comparar um carro esportivo novo com um Fusquinha.
{% details Comparação detalhada %}
Métricas de Performance do Node.js:
- Total de Requisições: 86.922 com uma taxa de 2.897,33 por segundo.
- Throughput: 1.449,29 requisições por segundo.
- Duração:
- Total: 55.135 segundos.
- Fase de Ataque: 30.001 segundos.
- Tempo de Espera: 25.134 segundos.
- Latências:
- Mínimo: 3.458 ms.
- Média: 7.079 segundos.
- Mediana (50º Percentil): 6.068 segundos.
- 90º Percentil: 9.563 segundos.
- 95º Percentil: 26.814 segundos.
- 99º Percentil: 30.001 segundos.
- Máximo: 30.004 segundos.
- Transferência de Dados:
- Bytes In: 2.077.556 (média 23.90 bytes/requisição).
- Bytes Out: 7.351.352 (média 84.57 bytes/requisição).
- Taxa de Sucesso: 91.93%.
- Códigos de Status: 7016 falhas, 79.906 sucessos (código 201).
Métricas de Performance do Golang:
-
Total de Requisições: 90.001 com uma taxa de 3.000,09 por segundo.
-
Throughput: 2.999,89 requisições por segundo.
-
Duração:
- Total: 30.001 segundos.
- Fase de Ataque: 29.999 segundos.
- Tempo de Espera: 2.035 ms.
-
Latências:
- Mínimo: 1.371 ms.
- Média: 60.454 ms.
- Mediana (50º Percentil): 4.773 ms.
- 90º Percentil: 194.115 ms.
- 95º Percentil: 453.031 ms.
- 99º Percentil: 736.873 ms.
- Máximo: 1.33 segundos.
-
Transferência de Dados:
- Bytes In: 2.430.027 (média 27.00 bytes/requisição).
- Bytes Out: 8.280.092 (média 92.00 bytes/requisição).
-
Taxa de Sucesso: 100%.
-
Códigos de Status: Todas as 90.001 requisições foram bem-sucedidas (código 201).
-
Throughput: Go teve um throughput maior comparado ao Node.
-
Latências: Node exibiu latências significativamente maiores, especialmente na média, 95º e 99º percentis.
-
Taxa de Sucesso: Go alcançou uma taxa de sucesso de 100%, enquanto Node teve uma taxa de sucesso menor com algumas requisições falhadas. {% enddetails %}
5.000 Requisições por segundo
Rodada final, vamos ver como ambas as linguagens lidam com pressão severa.
Latência x Segundos
Go foi capaz de manter uma latência muito baixa e estável até ~20 segundos, quando começou a apresentar alguns problemas, que causaram picos de 5s, que é muito lento.
Node apresentou problemas durante todo o teste, respondendo com latências entre 5-10s.
É bom notar que mesmo em um teste muito estressante, Go conseguiu ser estável durante todo o teste.

Contagem de File Descriptors
Mais uma vez podemos ver quão estáveis são os File Descriptors Abertos do Golang versus quão não gerenciados, crescendo linearmente eles são para o Node.js
Acredito que isso está diretamente relacionado ao Go Network Poller que reutiliza (e talvez pré-cria) File Descriptors ao invés de criar um no momento em que cada requisição chega.
Eu me pergunto se Node poderia se beneficiar de tal abordagem, definitivamente vou verificar isso 😅

Contagem de Threads
Neste gráfico podemos ver que Node começou com 11 OS Threads e pulou para 15 sempre que conexões começaram a chegar, enquanto Golang manteve 4 OS threads para a maioria do teste, aumentando para 5 no final.
A estratégia do Go parece ser mais estável sob cargas pesadas.

RAM
Node.js mostrou um aumento linear no uso de RAM, enquanto o aumento do Go foi em degraus, similar a subir uma escada.
Este padrão no Go é devido ao seu runtime gerenciando ativamente recursos e definindo limites para go routines, OS threads e file descriptors abertos.

CPU
O padrão de uso de CPU é muito similar para ambas as linguagens, sugerindo que isso pode estar fora do controle da linguagem, sendo delegado ao OS.

Geral
Go se destaca novamente com uma Taxa de Sucesso maior e latência p99, média, mínima e máxima menores.
Dado que Go é uma linguagem compilada, e Node.js (JavaScript) é interpretado, este resultado é esperado. Linguagens compiladas tipicamente têm menos passos antes de executar código de máquina.
Apesar de seus desafios inerentes, Node.js conseguiu processar com sucesso 89.38% das requisições.

Considerações Finais
Obrigado por dedicar tempo para ler este post do blog 🙏
Não é surpresa que Go, uma linguagem compilada focada em concorrência e paralelismo por design, saiu no topo. Ainda assim, foi interessante ver como tudo se desenrolou.
Foi legal ver como Go e Node.js lidam com tarefas de maneira diferente e como isso impacta os recursos do computador.
Resumi os pontos chave abaixo.
Gerenciamento de File Descriptor Abertos
- Go: Demonstra uma estratégia de pré-alocação e reutilização para File Descriptors, graças ao seu intelligent network poller e gerenciamento de recursos. Esta abordagem contribui para manuseio eficiente e escalabilidade sob cargas de rede pesadas.
- Node.js: Mostra um padrão dinâmico, talvez não gerenciado no uso de File Descriptor, refletindo sua abordagem para lidar com conexões de servidor e abrir FDs um por um.
Gerenciamento de Thread e Node.js
- Go: Mantém uma contagem de OS thread estável e baixa, destacando a eficiência de seu scheduler de runtime em otimizar o uso de thread, especialmente sob estresse pesado 🤯.
- Node.js: Ao contrário da crença popular, Node.js usa múltiplas threads para tarefas como DNS lookups, Garbage Collector (Oi V8), e operações de I/O async bloqueante, não é apenas uma single thread.
