jsguides

Client-Server Communication Patterns

REST APIs with fetch and axios

REST is the workhorse of web communication. Client-server communication in JavaScript typically starts with REST: it uses standard HTTP methods — GET to read, POST to create, PUT to update, DELETE to remove — and almost every API you consume will be REST at its core.

Using fetch

The Fetch API is built into every browser and works great in Node.js with a polyfill:

// GET request
const response = await fetch('/api/users/42');
if (!response.ok) {
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const user = await response.json();
console.log(user); // { id: 42, name: 'Alice', email: 'alice@example.com' }

Always check response.ok before parsing JSON. A 404 or 500 status still resolves the fetch; it only throws on network failures, not HTTP errors. This means you must handle both network-level failures (via catch) and HTTP-level errors (via response.ok) separately.

For requests that send data to the server, you need to tell the server what format you’re sending. For POST requests, set the Content-Type header explicitly and serialize the body with JSON.stringify:

const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' })
});

const created = await response.json();
console.log(created.id); // 43

Using axios

While fetch is built-in and works everywhere, it leaves several chores to you: checking response.ok, parsing JSON, and setting headers for every request. Axios is a popular library that simplifies error handling and provides a cleaner API. It throws on non-2xx responses automatically, serializes JSON request bodies for you, and parses JSON responses without an extra call. It also works in Node.js without polyfills:

import axios from 'axios';

// Simple GET
const { data: user } = await axios.get('/api/users/42');

// POST with automatic JSON serialization
const { data: created } = await axios.post('/api/users', {
  name: 'Alice',
  email: 'alice@example.com'
});

// With config: params, headers, timeout
const { data } = await axios.get('/api/users', {
  params: { page: 1, limit: 10 },
  headers: { Authorization: 'Bearer token123' },
  timeout: 5000
});

Axios throws on non-2xx responses automatically, so you don’t need to check response.ok. It also handles JSON serialization for you when you pass an object as data.

Setting up a base instance

When your app talks to the same API repeatedly, create a configured instance:

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
  headers: { 'X-Client': 'my-app' }
});

// Now all requests use these defaults
const { data } = await api.get('/users/42');
await api.post('/messages', { text: 'Hello' });

This centralizes configuration and keeps your request code DRY.

WebSockets for real-time communication

REST follows a request-response cycle: the client asks, the server answers, done. But some apps need the server to push data to the client without being asked. Chat messages, live dashboards, and collaborative documents all need a persistent connection.

WebSockets solve this. Once established, both client and server can send messages anytime, with much lower latency than HTTP polling.

Client-side WebSocket

const ws = new WebSocket('wss://api.example.com/ws');

ws.addEventListener('open', () => {
  console.log('Connected');
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'updates' }));
});

ws.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
});

ws.addEventListener('error', (error) => {
  console.error('WebSocket error:', error);
});

ws.addEventListener('close', (event) => {
  console.log(`Disconnected: code=${event.code} reason=${event.reason}`);
});

Note the wss:// protocol. Always use WebSockets over TLS in production. Plain ws:// is fine for local development.

The client-side API is event-driven: you attach listeners for open, message, error, and close. Each event gives you a different piece of the connection lifecycle. The server-side setup follows a similar pattern but operates on the connection stream itself. The ws library for Node.js listens for new connections and gives you a ws object per client, with the same event model for receiving messages and detecting disconnects:

Server-side WebSocket (Node.js with the ws library)

import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', (ws, req) => {
  const clientIp = req.socket.remoteAddress;
  console.log(`Client connected: ${clientIp}`);

  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    // Broadcast to all connected clients
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({ echo: msg }));
      }
    });
  });

  ws.on('close', () => console.log('Client disconnected'));
});

The server-side WebSocket setup broadcasts incoming messages to every connected client. Notice the readyState check. A client might have disconnected between the time you received the message and when you try to send, so always guard against sending to closed connections.

Handling reconnection

WebSocket connections can drop. Your client needs to reconnect. Networks are unreliable, and WebSocket connections don’t automatically restore themselves after a disconnection:

function connect() {
  const ws = new WebSocket('wss://api.example.com/ws');

  ws.onclose = () => {
    setTimeout(() => {
      reconnectAttempt++;
      connect();
    }, Math.min(1000 * 2 ** reconnectAttempt, 30000));
  };

  ws.onopen = () => {
    reconnectAttempt = 0;
  };

  return ws;
}

let reconnectAttempt = 0;
connect();

This implements exponential backoff. It retries quickly at first, then slows down to avoid overwhelming the server.

Server-Sent Events (SSE)

SSE is a simpler alternative to WebSockets when you only need one-way communication: server pushing to client, never the other way around. Think live notification feeds, dashboard updates, or streaming log output.

Browser SSE

const eventSource = new EventSource('/api/stream');

eventSource.addEventListener('update', (event) => {
  const data = JSON.parse(event.data);
  console.log('Update received:', data);
});

eventSource.onmessage = (event) => {
  const { type, payload } = JSON.parse(event.data);
  console.log(`${type}:`, payload);
};

eventSource.onerror = () => {
  if (eventSource.readyState === EventSource.CLOSED) {
    console.log('Connection closed, reconnecting...');
  }
};

// Clean up when done
eventSource.close();

EventSource is built into browsers. No library needed. It also handles reconnection automatically in most error cases, which makes it simpler than raw WebSockets for one-way data flows.

On the server side, SSE requires three specific headers and a streaming response. The browser expects text/event-stream as the content type, no-cache to prevent buffering, and keep-alive so the connection stays open between data chunks. Here’s how to set it up in Node.js:

Node.js SSE server

app.get('/api/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
  }, 1000);

  req.on('close', () => {
    clearInterval(interval);
    res.end();
  });
});

The key detail is calling res.flushHeaders() before writing. The headers need to be sent before the stream starts. Also, always clean up your intervals when the client disconnects using req.on('close').

A brief look at GraphQL

GraphQL is worth knowing because many modern APIs use it. Instead of multiple endpoints, you have one /graphql endpoint, and the client specifies exactly what data it needs:

const query = `
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
      posts(last: 5) {
        title
      }
    }
  }
`;

const response = await fetch('/graphql', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query, variables: { id: '42' } })
});

const { data, errors } = await response.json();
console.log(data.user.name); // Alice

The main advantage over REST is that you fetch exactly what you need in a single request: no over-fetching (getting more data than you need) or under-fetching (needing multiple requests to get related data). GraphQL is a good choice for complex frontends like mobile apps that have varied data needs.

For most simple CRUD applications, REST is still the right choice. GraphQL adds complexity that pays off when you have many different client types with different data requirements.

Common pitfalls to avoid

Forgetting timeouts. Without a timeout, a stuck request hangs forever. With fetch, use AbortController:

const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const response = await fetch('/api/data', { signal: controller.signal });

With axios, pass timeout: 5000 in the config. Timeouts matter more than they seem: without one, a slow or hung server keeps your request pending indefinitely, which can exhaust the browser’s connection pool and block other requests from going through.

Not handling CORS. Browsers block requests to a different origin. Your server needs to send Access-Control-Allow-Origin. In Express:

import cors from 'cors';
app.use(cors({ origin: 'https://your-app.com' }));

WebSockets bypass CORS, so validate the Origin header directly on the server instead. Unlike regular HTTP requests where browsers enforce CORS automatically, WebSocket handshakes carry an Origin header that your server must inspect manually to prevent unauthorized cross-origin connections.

Skipping error handling. Network requests fail. That’s normal. Always wrap requests in try/catch and handle specific error codes:

try {
  const { data } = await axios.get('/api/users');
} catch (error) {
  if (error.response?.status === 404) {
    console.log('User not found');
  } else {
    console.error('Request failed:', error.message);
  }
}

Which pattern to use

Choose based on your actual need:

  • REST (fetch/axios) for standard CRUD: create, read, update, delete resources. Most of your API calls will be this.
  • WebSockets for bidirectional, low-latency communication: chat, live collaboration, gaming.
  • SSE for server-to-client streaming where you don’t need the client to send messages: live feeds, dashboards, notifications.
  • GraphQL when your clients have very different data needs and you want a single flexible endpoint.

gRPC is used primarily for backend-to-backend communication where performance is critical. It requires a proxy for browser clients and is out of scope for this guide.

Most real applications use a combination. Your app might use REST for most operations, WebSockets for a live chat feature, and SSE for a notification bell.

Choose the simplest transport that fits

It is tempting to jump straight to WebSockets or GraphQL because they sound more advanced, but most applications do not need that complexity. REST is still the easiest place to start because it matches the way HTTP already works. If your feature only needs ordinary requests, keep it ordinary. Moving up to a more specialized protocol should be a response to a real communication problem, not a guess about future scale.

Keep the client and server contracts explicit

Whenever the browser and the server exchange data, write down the shape of the request and response as clearly as you can. That helps the frontend and backend stay in sync when one side changes. It also makes the error path easier to understand, because everyone can see what happened when the contract was broken. Clear contracts are one of the cheapest ways to avoid integration bugs.

Summary

Client-server communication is the backbone of any fullstack JavaScript app. REST APIs with fetch or axios cover the majority of use cases: simple request-response interactions. WebSockets give you persistent two-way channels for real-time features. SSE fills the gap when you only need server-to-client streams. GraphQL offers flexibility at the cost of added complexity.

Start with REST. Add WebSockets or SSE only when your app genuinely needs real-time updates. This keeps things simple until you have a real reason to add the complexity.

See Also