Destino Dev

Blog

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:

  1. Configurar pool com base em env: process.env.WORKER_POOL_SIZE ou cpus-1.
  2. Implementar timeout por tarefa com rejeição e lógica de retry limitada.
  3. Monitorar métricas: tasks queued, tasks running, task latency, worker uptime, memory per process.
  4. Graceful shutdown: terminar workers com .terminate() e aguardar tarefas pendentes.
  5. 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