Paralelizando resolvers CPU-bound no Apollo/GraphQL com Worker Threads e pool
Por John Pires · 31 de março de 2026

Paralelizando resolvers CPU-bound no Apollo/GraphQL com Worker Threads e pool
1) Problema real que resolve
APIs GraphQL são convenientes para agregar dados e executar computações no backend diretamente nos resolvers. Porém, quando um resolver executa trabalho CPU-bound (ex.: processamento de imagens, cálculos matemáticos intensos, criptografia, compressão), o Node.js pode ficar bloqueado porque o JavaScript roda em uma única thread de execução por processo. O resultado prático é latência alta, throughput reduzido e degradação em cascata das consultas concorrentes.
Em serviços Apollo/GraphQL com cargas médias a altas, um único resolver pesado pode derrubar a qualidade da API inteira. Isso é particularmente visível em endpoints que aceitam múltiplas consultas paralelas: se uma requisição engatilha uma operação com 300 ms de CPU intensa, todas as outras esperam.
Este artigo detalha um padrão prático: extrair trabalho CPU-bound para worker threads, gerenciar esses workers com um pool e integrar isso a resolvers Apollo de forma segura e eficiente.
Por que não só escalar horizontalmente?
Escalar instâncias (mais pods/VMs) é parte da solução, mas tem custo e latência de cold-start. Se a raiz do problema é o event loop bloqueado, usar worker threads dentro da mesma instância preserva recursos e reduz custos. Além disso, um pool bem feito evita criar/destrocar threads para cada requisição — uma operação cara.
Quando aplicar esta solução
Use worker threads quando:
- O trabalho é fortemente CPU-bound e não pode ser facilmente delegável a um serviço externo (ou você prefere evitar rede).
- Você precisa de baixa latência e alto throughput sem aumentar instâncias (ou enquanto escala).
- O trabalho é isolável por mensagem (entrada → saída), sem dependências profundas de contexto do Node global.
2) Abordagem ingênua vs abordagem otimizada
Antes de entrar no código, comparar duas abordagens é útil para entender ganhos e trade-offs.
Abordagem ingênua
A abordagem ingênua é executar a função CPU-bound diretamente no resolver. Exemplo comum: algoritmo recursivo, compressão síncrona ou cálculo de hash sendo executado inline. Isso bloqueia o event loop e reduz concorrência. É simples, mas inviável em produção com carga.
// Exemplo ingênuo: resolver que calcula fibonacci de forma recursiva (bloqueante)
const resolvers = {
Query: {
fib: (_, { n }) => {
function fibRec(k) {
if (k < 2) return k;
return fibRec(k - 1) + fibRec(k - 2);
}
return fibRec(n);
}
}
};
Contexto: esse código ilustra o problema — um cálculo simples que, para n>35, consome CPU por centenas de ms. Depois: qualquer requisição concorrente fica em fila no event loop.
Abordagem otimizada
Separar o trabalho CPU-bound para worker threads e usar um pool para evitar custo de spawn em cada chamada. A solução envolve: 1) um worker script que executa a função; 2) um pool com filas e reutilização de workers; 3) integração com resolvers via API assíncrona (promises).
Vantagens: não bloqueia o event loop, melhor latência p95, controle sobre paralelismo efetivo (número de workers) e facilidade para implementar timeouts e circuit breakers.
3) Implementação com código real
O exemplo a seguir implementa um pool simples de worker threads e integra com Apollo Server. Vamos considerar um serviço Node.js 16+ com suporte nativo a worker_threads.
Estrutura de arquivos
Estruture o projeto assim (exemplo minimal):
/project
/workers
cpu-worker.js // worker que executa a tarefa
worker-pool.js // implementação do pool
server.js // Apollo server e resolvers
package.json
Contexto: vamos mostrar cada arquivo com explicação. Depois de cada bloco, descrevo o que acontece.
cpu-worker.js (o que faz)
O worker recebe uma mensagem com a tarefa e retorna o resultado. Aqui usamos um cálculo de hash de simulação ou um cálculo intensivo (fibonacci não é ótimo para produção, mas serve como exemplo didático).
// workers/cpu-worker.js
const { parentPort } = require('worker_threads');
function heavyComputation(input) {
// Exemplo de CPU-bound: cálculo simples e determinístico
// Pode ser substituído por processamento de imagem, compressão, etc.
let n = input.n;
function fib(k) {
if (k < 2) return k;
return fib(k - 1) + fib(k - 2);
}
return { n, result: fib(n) };
}
parentPort.on('message', (task) => {
try {
const out = heavyComputation(task.payload);
parentPort.postMessage({ id: task.id, result: out });
} catch (err) {
parentPort.postMessage({ id: task.id, error: err.message });
}
});
O que acontece: o worker fica aguardando mensagens; ao receber, executa o cálculo e envia um postMessage com id e resultado. Erros são serializados.
worker-pool.js (o que faz)
O pool mantém N workers, uma fila de requisições e atua como intermediário assíncrono. Ele usa promises para expor uma chamada run(task) que resolve quando o worker responde.
// worker-pool.js
const { Worker } = require('worker_threads');
const path = require('path');
class WorkerPool {
constructor(workerPath, size = require('os').cpus().length - 1) {
this.workerPath = workerPath;
this.size = Math.max(1, size);
this.free = [];
this.busy = new Map();
this.queue = [];
for (let i = 0; i < this.size; i++) this._spawn();
}
_spawn() {
const worker = new Worker(this.workerPath);
worker.on('message', (msg) => this._handleResponse(worker, msg));
worker.on('error', (err) => this._handleError(worker, err));
worker.on('exit', (code) => this._handleExit(worker, code));
this.free.push(worker);
}
_handleResponse(worker, msg) {
const { id, result, error } = msg;
const entry = this.busy.get(worker);
if (!entry || entry.id !== id) {
// mensagem fora de ordem — ignorar ou log
return;
}
this.busy.delete(worker);
this.free.push(worker);
if (error) entry.reject(new Error(error));
else entry.resolve(result);
this._next();
}
_handleError(worker, err) {
const entry = this.busy.get(worker);
if (entry) entry.reject(err);
this._removeWorker(worker);
this._spawn();
}
_handleExit(worker, code) {
// re-spawn worker para manter pool size
this._removeWorker(worker);
this._spawn();
}
_removeWorker(worker) {
this.busy.delete(worker);
this.free = this.free.filter(w => w !== worker);
}
_next() {
if (!this.queue.length) return;
if (!this.free.length) return;
const { payload, resolve, reject } = this.queue.shift();
const worker = this.free.pop();
const id = Math.random().toString(36).slice(2);
this.busy.set(worker, { id, resolve, reject });
worker.postMessage({ id, payload });
}
run(payload, opts = {}) {
return new Promise((resolve, reject) => {
// opcional: implementar timeout usando opts.timeout
this.queue.push({ payload, resolve, reject });
this._next();
});
}
destroy() {
for (const w of this.free) w.terminate();
for (const w of this.busy.keys()) w.terminate();
this.free = [];
this.busy.clear();
this.queue = [];
}
}
module.exports = WorkerPool;
O que acontece: o pool cria workers, mantém listas de livres e ocupados, e uma fila FIFO para solicitações. Cada run() retorna uma Promise que resolve quando o worker responde.
Integrando com Apollo (server.js)
Agora, integramos o pool ao resolver do Apollo. O resolver dispara a tarefa no pool e retorna a promise ao cliente GraphQL.
// server.js
const { ApolloServer, gql } = require('apollo-server');
const path = require('path');
const WorkerPool = require('./worker-pool');
const typeDefs = gql`
type FibResult { n: Int!, result: Int! }
type Query { fib(n: Int!): FibResult }
`;
const pool = new WorkerPool(path.resolve(__dirname, './workers/cpu-worker.js'), 2);
const resolvers = {
Query: {
fib: async (_, { n }) => {
// delega para o pool
const out = await pool.run({ n }, { /* timeout: 1000 */ });
return out.result;
}
}
};
const server = new ApolloServer({ typeDefs, resolvers });
server.listen({ port: 4000 }).then(({ url }) => {
console.log('Server ready at', url);
});
// on shutdown: pool.destroy();
O que acontece: cada chamada GraphQL fica assíncrona. O event loop não bloqueia; o pool regula quantas tarefas rodam em paralelo (configurado como 2 neste exemplo).
4) Testes e validação
Não basta implementar: precisamos medir e validar. Aqui estão passos práticos para testar ganho de performance e confirmar ausência de bloqueo do event loop.
Teste funcional
Com o servidor rodando, execute várias consultas concorrentes. Um teste rápido com o client GraphQL ou curl mostra que respostas chegam sem bloquear o server.
// script simples de teste concurrente (test/bench.js)
const fetch = require('node-fetch');
const query = JSON.stringify({ query: '{ fib(n: 40) { n result } }' });
async function runParallel(count) {
const jobs = Array.from({ length: count }, () =>
fetch('http://localhost:4000', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: query }).then(r => r.json())
);
return Promise.all(jobs);
}
runParallel(10).then(results => console.log('done', results.length)).catch(console.error);
Contexto: esse script dispara 10 requisições paralelas. Com a solução de pool, o event loop não deve ficar bloqueado e o throughput será limitado pelo número de workers, não pela fila do Node falso-concorrente.
Benchmark de carga
Use autocannon ou artillery para simular tráfego e medir latências p50/p95/p99. Compare duas execuções: com função inline (bloqueante) e com pool. Você verá latências p95 menores e throughput maior com pool, apesar do consumo CPU real similar.
// comando autocannon (instale globalmente ou local):
// autocannon -c 50 -d 10 -m POST -H "content-type: application/json" -b '{"query":"{ fib(n:40){n result}}"}' http://localhost:4000
// Interprete: observe latências e requisições/s
O que acontece: o benchmark revela que, no cenário bloqueante, latências sob concorrência disparam. Com pool, latências se estabilizam enquanto número de requisições completadas aumenta até limite do pool.
5) Considerações de produção
Implementar worker threads em produção tem detalhes práticos que não podem ser ignorados. Abaixo, uma lista de pontos essenciais e armadilhas comuns.
Erros comuns e armadilhas
Listagem dos problemas que eu já vi em projetos reais:
- Sem limite no pool: criar mais workers que CPU disponíveis causa thrashing e overhead de context switch. Configure pool com <= cpus-1.
- Não tratar timeouts: se um worker travar numa operação longa, o cliente fica esperando indefinidamente. Implementar timeout e fallback (circuit breaker).
- Mensagens grandes demais: passar grandes buffers/objetos entre threads pode custar tempo de serialização. Use SharedArrayBuffer ou streams quando necessário.
- Falta de isolamento de erros: um erro no worker deve ser capturado e não derrubar o processo principal. Sempre trate event listeners 'error' e 'exit'.
- Fuga de memória: manter referências a objetos grandes nas filas pode causar OOM. Monitore memória.
Regra prática: medir antes e depois. Nem toda CPU-bound precisa de worker threads; algumas tarefas são melhores como serviços dedicados ou functions-as-a-service.
Operações essenciais para produção
Checklist mínimo:
- Configurar pool com base em env: process.env.WORKER_POOL_SIZE ou cpus-1.
- Implementar timeout por tarefa com rejeição e lógica de retry limitada.
- Monitorar métricas: tasks queued, tasks running, task latency, worker uptime, memory per process.
- Graceful shutdown: terminar workers com .terminate() e aguardar tarefas pendentes.
- Testar cenários de falha: worker crash, payload inválido, serialização falha.
Exemplo rápido de timeout no pool (adendo):
// uso de timeout simples ao chamar pool.run
async function runWithTimeout(pool, payload, ms) {
return Promise.race([
pool.run(payload),
new Promise((_, reject) => setTimeout(() => reject(new Error('worker timeout')), ms))
]);
}
Contexto: essa abordagem simples rejeita após ms milissegundos. Em produção, preferir cancelar a tarefa no worker se possível ou marcar o worker para reinício.
Conclusão prática
Offload de tarefas CPU-bound para worker threads com um pool é uma técnica eficaz para melhorar latência e throughput em servidores Apollo/GraphQL. A estratégia reduz bloqueios no event loop, oferece controle sobre o paralelismo e diminui o impacto de um resolver pesado sobre a API inteira.
Resumindo os passos práticos:
- Identifique resolvers CPU-bound e priorize aqueles que impactam a latência agregada.
- Isolar a lógica em um worker script que recebe mensagens e retorna resultados.
- Implementar um pool com tamanho baseado em CPUs, filas e tratamento de erros/timeouts.
- Integrar aos resolvers retornando promisses; monitorar e testar com carga.
Na prática, essa solução reduz p95/p99 sem alterar arquitetura externa, e pode ser combinada com outras estratégias (caching, rate limiting, filas externas) para robustez adicional.
Ao adotar worker threads, mantenha disciplina: limite do pool, timeouts, métricas e testes de falha. Esses detalhes transformam um protótipo correto em uma solução de produção confiável.
Assuntos: Node.js, GraphQL, apollo, worker-threads, Performance, pool
Leia também
Processando CSVs grandes com Node.js Streams sem estourar memória
Guia passo a passo para processar CSVs grandes em Node.js sem estourar memória: exemplos práticos com streams, csv-parse, readline e worker threads, focado em…
Processar uploads CSV grandes com Streams, Worker Threads e progresso via SSE em Node.js
Processar CSV grandes sem travar o Node: mostro o jeito errado (ler tudo) e a solução correta com streams, worker threads e SSE para progresso em tempo real…
Rotação segura de refresh tokens JWT: detectar reuse e revogar sessões
Padrão prático para rotacionar refresh tokens JWT com detecção de reuse: como modelar sessões no Redis, atualizar currentJti de forma atômica, revogar sessões…
Abortar pipelines de Streams em Node.js: AbortController, timeouts e limpeza de Workers
Guia prático para abortar pipelines de streams em Node.js: uso de AbortController, patterns de timeout, e limpeza segura de Worker Threads para evitar…
Diagnóstico de latência em APIs Node.js com async_hooks, event-loop e GC
Diagnostique latência em APIs Node.js combinando async_hooks/AsyncLocalStorage, monitorEventLoopDelay e análise de GC. Passo a passo prático: medir…
Gerar thumbnails e checksums de uploads usando Streams e Worker Threads no Node.js
Como gerar thumbnails e checksums de uploads sem sobrecarregar o Node: padrões com Streams (PassThrough/pipeline) e offload para Worker Threads. Boas…