Client-Server Communication Patterns

· 7 min read · Updated March 20, 2026 · intermediate
javascript node web

REST APIs with fetch and axios

REST is the workhorse of web communication. 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.

For POST requests, set the Content-Type header explicitly:

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

Axios is a popular library that simplifies error handling and provides a cleaner API. 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, collaborative documents — these 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.

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'));
});

Handling reconnection

WebSocket connections can drop. Your client needs to reconnect:

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.

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.

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.

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.

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