▶ Slides ← Módulo
Módulo 2 · Ciclo Comum · IN02 Aula 8 de 11

Front-End II — JavaScript Assíncrono, fetch() e Redes

A página renderizada por SSR vira um cliente vivo: chama a API, trata erros, cancela requests, mostra estados de carregamento

1. De SSR estático para UI viva

Na aula 7 nós renderizamos páginas com EJS: o HTML chega pronto do servidor com os dados embutidos, e o JS no cliente faz pequenas interações com a DOM. Funciona. Mas qualquer ação significativa do usuário (criar registro, favoritar, filtrar) ainda exige recarregar a página inteira.

A aula 8 muda isso: usamos fetch() no navegador para conversar com endpoints JSON do nosso back-end TypeScript+Express. Resultado: a página continua sendo entregue por SSR (rápida, indexável, com sessão), mas ações específicas atualizam só pedaços da tela.

Modelo mental do dia

SSR entrega a casa pronta. fetch() + JS são os interruptores e tomadas: o usuário interage com partes da casa sem demolir e reconstruir tudo a cada clique.

Para isso funcionar bem, você precisa entender três blocos:

  1. Como JS faz coisas assíncronas sem travar a interface (event loop, Promises, async/await).
  2. Como pedir e enviar dados pela rede (fetch, headers, JSON, erros).
  3. Como representar estados de UI que dependem de uma resposta que pode demorar ou falhar.

2. Event loop e tarefas

JavaScript é single-threaded. Há uma única thread executando seu código. Então, como o browser consegue fazer várias coisas ao mesmo tempo (animação rolando, request de rede em andamento, usuário digitando)?

A resposta é o event loop. O motor JS tem três estruturas principais:

  • Call stack — pilha de funções em execução agora.
  • Microtask queue — fila de callbacks de Promises (.then, await resumindo).
  • Macrotask queue — fila de timers, eventos do DOM, callbacks de fetch resolvido, I/O.

O ciclo é simples e implacável:

  1. O JS executa tudo o que está na call stack até esvaziar.
  2. Esvaziou? Drena todas as microtasks da fila — uma após a outra.
  3. Pega uma macrotask, coloca na stack, volta ao passo 1.
event-loop-quiz.js
console.log('1. sync');

setTimeout(() => console.log('4. macrotask'), 0);

Promise.resolve().then(() => console.log('3. microtask'));

console.log('2. sync');

// Saída: 1, 2, 3, 4 — microtasks SEMPRE rodam antes da próxima macrotask
Cuidado

Loops infinitos ou cálculos pesados na call stack travam o event loop e congelam a UI. Por isso operações I/O (rede, disco) são sempre assíncronas — elas não ocupam a thread principal enquanto esperam.

3. Promises

Uma Promise é um objeto que representa um valor que ainda não chegou — um contrato do tipo "vou te entregar este resultado depois, ou te avisar que falhou". Tem três estados:

pending
Estado inicial. A operação ainda não terminou.
fulfilled
Resolveu com um valor. Dispara callbacks de .then.
rejected
Falhou com um erro. Dispara callbacks de .catch.

Uma vez que sai do pending, a Promise nunca volta — fica selada como fulfilled ou rejected.

Métodos de instância

  • .then(onFulfilled, onRejected?) — callback de sucesso (e opcionalmente de erro).
  • .catch(onRejected) — callback só de erro. Equivalente a .then(undefined, fn).
  • .finally(fn) — roda sempre, sucesso ou erro. Útil para fechar loaders.
promises-chain.js
fetch('/api/users')
  .then(res => res.json())
  .then(data => renderUsers(data))
  .catch(err => renderError(err))
  .finally(() => hideSpinner());

Métodos estáticos

MétodoComportamentoQuando usar
Promise.all([p1, p2, p3]) Resolve quando todas resolvem. Rejeita assim que uma rejeita. Carregar dados independentes em paralelo (usuário + posts + tags).
Promise.allSettled([p1, p2, p3]) Espera todas, sem falhar. Devolve array de { status, value | reason }. Quando você precisa do resultado de todas, mesmo que algumas falhem.
Promise.race([p1, p2]) Resolve/rejeita assim que a primeira resolver/rejeitar. Implementar timeouts: corre fetch contra um setTimeout.
Promise.any([p1, p2]) Resolve com a primeira que resolver. Só rejeita se todas falharem. Tentar várias fontes redundantes e ficar com a primeira boa.

4. async / await

async/await é açúcar sintático em cima de Promises. Não substitui Promise — apenas escreve o mesmo código de uma forma que se lê como código síncrono.

Regras de ouro

  • async function sempre retorna uma Promise. Mesmo um return 42 vira Promise<42>.
  • await só pode aparecer dentro de uma função async (ou no topo de um ES module).
  • Erros em async viram exceções normais — use try/catch.
  • await pausa a função, mas não bloqueia a thread. Outras tarefas continuam rodando.
async-await.ts (cliente)
// Promises encadeadas
function carregar(id) {
  return fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(user => renderUser(user))
    .catch(err => mostrarErro(err));
}

// Mesmo código com async/await — mais legível
async function carregar(id) {
  try {
    const res  = await fetch(`/api/users/${id}`);
    const user = await res.json();
    renderUser(user);
  } catch (err) {
    mostrarErro(err);
  }
}
Erro clássico

SyntaxError: await is only valid in async functions — você usou await dentro de uma função normal. Adicione async à declaração da função, ou mova o await para uma função wrapper.

Paralelismo: cuidado com await sequencial

Cada await faz a função pausar até a Promise resolver. Se você precisa de 3 dados independentes, esperar um por um é desperdício.

sequencial-vs-paralelo.ts
// Sequencial: 3 viagens, uma após a outra. Total ~ 3x latência
const u = await fetch('/api/users').then(r => r.json());
const p = await fetch('/api/posts').then(r => r.json());
const t = await fetch('/api/tags').then(r => r.json());

// Paralelo: 3 viagens ao mesmo tempo. Total ~ 1x latência
const [u, p, t] = await Promise.all([
  fetch('/api/users').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/tags').then(r => r.json()),
]);

5. fetch() — GET

A Fetch API é a interface moderna do navegador para fazer requests HTTP. Substitui XMLHttpRequest. Está disponível em todos os navegadores modernos e também no Node.js 18+.

Forma básica:

public/js/api.js — helper
// Sem segundo argumento, fetch usa method GET por default
export async function getJSON(url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status} em ${url}`);
  return await res.json();
}

Pontos importantes do objeto Response:

  • res.oktrue se status entre 200 e 299.
  • res.status — código numérico (200, 404, 500...).
  • res.statusText — descrição textual ("OK", "Not Found").
  • res.headers — objeto Headers; use res.headers.get('Content-Type').
  • res.json(), res.text(), res.blob() — todos retornam Promise. Você precisa de await.
Carregar a página inteira via SSR e a lista via fetch?

Sim — esse é exatamente o padrão da nossa stack. O SSR entrega o HTML com a estrutura e o usuário vê algo imediatamente. O JS do cliente, quando carrega, faz fetch('/api/users') para popular a parte dinâmica. Isso desliga acoplamento entre layout e dados.

6. fetch() — POST com JSON

Para enviar dados ao servidor, passamos um segundo argumento ao fetch com método, headers e body:

public/js/api.js — postJSON helper
export async function postJSON(url, payload) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    const erro = await res.json().catch(() => ({}));
    throw new Error(erro.message ?? `HTTP ${res.status}`);
  }

  return await res.json();
}
Três obrigatoriedades em POST com JSON

1. method: 'POST' — sem isso vira GET silenciosamente, e seu controller no Express jamais será chamado.

2. 'Content-Type': 'application/json' — o middleware express.json() só parseia o corpo se este header estiver presente. Sem ele, req.body chega vazio.

3. JSON.stringify(payload) — passar o objeto cru transforma em "[object Object]" no corpo. Você precisa serializar manualmente.

Outros métodos seguem o mesmo padrão:

  • method: 'PUT' ou 'PATCH' para atualizar.
  • method: 'DELETE' normalmente sem body.

7. Erro de rede vs erro HTTP — o pulo do gato

Aqui está o detalhe que mais derruba quem está começando: fetch() NÃO rejeita por status 4xx ou 5xx. A Promise resolve com a Response, e o status apenas indica que algo deu errado no servidor.

CenárioPromise rejeita?Por quê
Sem internetSIMNão foi possível alcançar o servidor.
DNS falhouSIMHost não existe.
CORS bloqueouSIMResposta nem chegou ao seu código.
Request abortadoSIMCancelamento via AbortController.
404 Not FoundNÃOServidor respondeu — ele só disse que não achou.
422 Validation ErrorNÃOServidor respondeu (com seu zod reclamando).
500 Internal ErrorNÃOServidor respondeu com falha.

Por isso a checagem if (!res.ok) throw new Error(...) é obrigatória em qualquer wrapper de fetch:

padrão correto
async function getJSON(url) {
  const res = await fetch(url);
  if (!res.ok) {
    // res.ok é true só para status 200-299
    throw new Error(`HTTP ${res.status} ${res.statusText}`);
  }
  return await res.json();
}

8. AbortController — cancelando requests

Imagine um campo de busca que dispara um fetch a cada tecla. O usuário digita "afo", "afon", "afons", "afonso". Cinco requests viajam pela rede em ordem, mas a resposta pode chegar fora de ordem. Você acaba mostrando resultados de uma busca antiga em cima da nova — bug clássico.

A solução é AbortController: gerar um signal que pode ser usado para cancelar requests pendentes.

public/js/search.js
let controller = null;

input.addEventListener('input', async (e) => {
  // Cancela request anterior (se ainda em voo)
  if (controller) controller.abort();
  controller = new AbortController();

  try {
    const res = await fetch(
      `/api/search?q=${encodeURIComponent(e.target.value)}`,
      { signal: controller.signal }
    );
    renderResultados(await res.json());
  } catch (err) {
    if (err.name === 'AbortError') return; // silencioso e esperado
    mostrarErro(err);
  }
});

Quando usar:

  • Typeahead / autocomplete — substitui o request anterior a cada tecla.
  • Troca de aba ou rota — cancela o que estava carregando para a aba antiga.
  • Timeout — combine com setTimeout(() => controller.abort(), 5000).

9. Loading / Empty / Error / Success

Toda UI que depende de uma resposta assíncrona vive em quatro estados. Esquecer um deles é a fonte mais comum de UI quebrada na vida real.

Loading

Request em andamento. Mostre spinner, skeleton, ou pelo menos uma mensagem.

setLoading()

Empty

Resposta OK, mas data.length === 0. NÃO é erro — é vazio. Mensagem amigável + CTA.

"Nenhum item ainda. Criar?"

Error

Rede ou status >= 400. Mostre o erro e botão "tentar novamente".

renderError(err.message)

Success

Renderiza a lista, tabela, formulário preenchido — o caminho feliz.

renderList(data)

public/js/users-list.js — esqueleto canônico
import { getJSON } from './api.js';

const root = document.querySelector('#lista-usuarios');

async function carregar() {
  renderLoading();
  try {
    const { data } = await getJSON('/api/users');
    if (data.length === 0) return renderEmpty();
    renderList(data);
  } catch (err) {
    renderError(err);
  }
}

function renderLoading() { root.innerHTML = '<p class="loading">Carregando...</p>'; }
function renderEmpty()   { root.innerHTML = '<p>Nenhum usuário cadastrado ainda.</p>'; }
function renderError(err) {
  root.innerHTML = `<p class="error">Falha: ${err.message}</p>`;
}
function renderList(users) {
  root.innerHTML = users.map(u => `<li>${u.name}</li>`).join('');
}

carregar();

10. Backend dual — HTML + JSON reusando o service

O segredo de manter o código limpo é não duplicar lógica. O mesmo UserService que serve a página EJS também serve o endpoint JSON. O que muda é apenas o controller: um chama res.render, o outro chama res.json.

src/controllers/UserController.ts
import { Request, Response } from 'express';
import { UserService } from '../services/UserService';

export class UserController {
  constructor(private readonly service: UserService) {}

  // HTML — renderiza EJS para carga inicial
  index = async (req: Request, res: Response) => {
    const users = await this.service.listAll();
    res.render('users/index', { users });
  };

  // JSON — endpoint da API
  apiList = async (req: Request, res: Response) => {
    const users = await this.service.listAll();
    res.json({ data: users });
  };
}
src/routes/users.ts
const ctrl = new UserController(new UserService(new UserRepository(db)));

// Rotas HTML
router.get('/users',         ctrl.index);
router.get('/users/:id',     ctrl.show);

// Rotas JSON (mesmo controller, métodos diferentes)
router.get('/api/users',     ctrl.apiList);
router.post('/api/users',    ctrl.apiCreate);

Padrão de envelope JSON

Existem duas escolas. Não há certo — escolha uma e seja consistente:

EstiloResposta de sucessoResposta de erro
Direto res.json(users)[...] res.status(404).json({ message: '...' })
Envelope res.json({ data: users }) res.status(404).json({ error: { message: '...' } })

11. CORS resumido

CORS (Cross-Origin Resource Sharing) é um mecanismo do navegador que bloqueia requests entre origens diferentes, a menos que o servidor diga explicitamente "tudo bem".

Origem = protocolo + host + porta. Por exemplo:

  • http://localhost:3000 e http://localhost:3000 → mesma origem.
  • http://localhost:3000 e http://localhost:8080 → origens diferentes.
  • https://app.x.com e https://api.x.com → origens diferentes.
No nosso projeto, CORS não é problema

O Express serve EJS e JSON da mesma origem (localhost:3000). O navegador aceita o fetch('/api/...') sem questionar. Use caminhos relativos.

Quando o problema aparece

Se em produção você separar front (app.x.com) e API (api.x.com), o navegador bloqueia. Solução: instalar o middleware cors no Express e configurar origin: 'https://app.x.com'.

12. Headers comuns

HeaderDireçãoFunção
Content-Type Cliente → Servidor (e vice-versa) Tipo do corpo. application/json é o nosso padrão para POST/PUT.
Accept Cliente → Servidor O que o cliente sabe ler. Servidor pode escolher formato baseado nele.
Authorization Cliente → Servidor Token bearer (Bearer xyz...) ou Basic. Não usaremos hoje — usamos sessão.
Cache-Control Servidor → Cliente Política de cache (no-store, max-age=300, ...).
Cookie Cliente → Servidor Cookies do domínio. Vão automáticos em mesma origem.
Set-Cookie Servidor → Cliente Define um cookie no browser. Atributos: HttpOnly, Secure, SameSite.

13. Cookies de sessão e SameSite

Quando o servidor cria uma sessão (login, carrinho), ele responde com um Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax. O navegador armazena. A partir daí, todo request para o mesmo domínio anexa o cookie automaticamente — inclusive os fetch().

  • HttpOnly — JS não consegue ler (proteção contra XSS).
  • Secure — só envia em HTTPS.
  • SameSite=Lax (default seguro) — cookie envia em GETs cross-site (links externos), mas não em POSTs cross-site (ataques CSRF).
  • SameSite=Strict — só mesma origem.
  • SameSite=None; Secure — cross-site permitido (usado quando frontend e API são origens diferentes).
No nosso projeto

Sessão é gerenciada pelo Express + express-session ou similar. Como front e back compartilham origem, o cookie viaja transparente em todos os fetch() — sem precisar mexer em credentials.

Cross-origin: cuidado com credentials

Se um dia usar fetch entre origens diferentes e quiser cookies, precisa de fetch(url, { credentials: 'include' }) + cors({ credentials: true }) no servidor + SameSite=None; Secure no cookie. Há atrito proposital.

14. Network tab do DevTools

F12 → Network. É onde você diagnostica 90% dos problemas de integração front/back.

  • URL e método — confirma para onde foi e como.
  • Status — colorido: 200 verde, 3xx azul, 4xx amarelo, 5xx vermelho.
  • Headers (request e response) — útil para conferir Content-Type, Cookie, etc.
  • Payload — corpo enviado.
  • Response — corpo recebido (JSON pré-formatado).
  • Timing — DNS, conexão, espera, download. Onde está a lentidão?
Dica de produtividade

Filtre por Fetch/XHR para esconder requests de assets (imagens, CSS) e ver só as chamadas da sua API. Marque "Preserve log" para manter o histórico ao navegar.

15. Exemplo end-to-end — botão "Favoritar"

Vamos juntar todo o conhecimento da aula em um caso real: a página users/show.ejs mostra um usuário com um botão "Favoritar". Clique aciona POST /api/users/:id/favorite, atualiza o estado no servidor e a UI muda sem recarregar.

15.1 — A view EJS (carga inicial)

views/users/show.ejs
<article class="user-card" data-user-id="<%= user.id %>">
  <h1><%= user.name %></h1>
  <p><%= user.email %></p>
  <button id="btn-fav" aria-pressed="<%= user.favorited %>">
    <%= user.favorited ? 'Favorito' : 'Favoritar' %>
  </button>
</article>

<script type="module" src="/js/favorite.js"></script>

15.2 — O JS do cliente

public/js/favorite.js
import { postJSON } from './api.js';

const btn  = document.querySelector('#btn-fav');
const card = btn.closest('.user-card');
const id   = card.dataset.userId;

btn.addEventListener('click', async () => {
  btn.disabled = true;
  const textoOriginal = btn.textContent;
  btn.textContent = 'Salvando...';

  try {
    const { favorited } = await postJSON(`/api/users/${id}/favorite`, {});
    btn.textContent  = favorited ? 'Favorito' : 'Favoritar';
    btn.setAttribute('aria-pressed', String(favorited));
  } catch (err) {
    btn.textContent = textoOriginal;
    alert('Falha ao favoritar: ' + err.message);
  } finally {
    btn.disabled = false;
  }
});

15.3 — O endpoint no servidor

src/controllers/UserController.ts
apiToggleFavorite = async (req: Request, res: Response) => {
  const id = Number(req.params.id);
  if (!id) return res.status(400).json({ message: 'id inválido' });

  const favorited = await this.service.toggleFavorite(id);
  res.json({ favorited });
};

// router.post('/api/users/:id/favorite', ctrl.apiToggleFavorite);
O que esse exemplo demonstra

SSR entrega o estado inicial. fetch + async/await consultam o back-end. try/catch/finally trata erro e estado de loading. A UI muda sem recarregar a página. Esse padrão escala para likes, comentários, exclusão, edição inline — qualquer interação da sua aplicação.

16. Intro a TCP/IP, DNS e HTTPS

Quando você escreve fetch('/api/users'), uma cascata acontece por baixo. Vamos vê-la em alto nível agora — a aula 11 abre cada caixa-preta dessa.

🔎
DNS
Resolve nome do host em IP.
🤝
TCP
Handshake e conexão confiável.
🔒
TLS
Criptografia (HTTPS).
📨
HTTP
Request/Response em texto.
  • DNS — quando o cliente pede https://api.x.com/users, primeiro um servidor DNS traduz api.x.com em IP (ex.: 54.230.10.20).
  • TCP/IP — protocolos da camada de transporte que garantem entrega ordenada e confiável de pacotes entre cliente e servidor.
  • TLS / HTTPS — uma camada de criptografia que envelopa o HTTP. https:// = HTTP rodando sobre TLS.
  • HTTP — o protocolo de aplicação. Um request é texto: linha de método/path, headers, linha em branco, body.
Para a aula 11

Em duas semanas vamos detalhar o handshake TCP, a negociação TLS, o que aparece em um trace de rede e por que latência geográfica importa. Por enquanto, basta ter o mapa: nome → IP → conexão → criptografia → request.

17. Checklist da aula

  • Sei diferenciar microtasks (Promises) de macrotasks (setTimeout) e prever a ordem de execução.
  • Conheço os três estados de uma Promise e os métodos .then, .catch, .finally.
  • Sei usar Promise.all, Promise.allSettled e Promise.race.
  • Reconheço que await só funciona dentro de função async.
  • Faço fetch GET com tratamento de res.ok.
  • Faço fetch POST com method, Content-Type e JSON.stringify.
  • Sei explicar por que fetch NÃO rejeita em status 4xx/5xx.
  • Implemento cancelamento com AbortController e ignoro AbortError.
  • Toda chamada na minha UI tem os 4 estados: loading, empty, error, success.
  • Tenho o mesmo controller respondendo HTML (res.render) e JSON (res.json), reusando o service.
  • Sei abrir o Network tab e diagnosticar status, headers e payload.
  • Tenho um helper api.js com getJSON e postJSON reusáveis.

18. Referências

Inteli Logo