Making HTTP Requests โ€” Client Side ยท Astro Tech Blog

Client-Side HTTP in Node.js

Node.js provides several ways to make outgoing HTTP requests:

APIBuilt-in?Promises?Stream?
http.get()Yes (core)No (callback)Yes
http.request()Yes (core)No (callback)Yes
fetch()Yes (Node 18+, global)YesYes
undiciYes (core, Node 20+)YesYes

1. http.get โ€” Simple GET Requests

The simplest way to fetch data:

const http = require('http');

http.get('http://api.example.com/users', (res) => {
  let data = '';

  // Collect response chunks
  res.on('data', (chunk) => {
    data += chunk;
  });

  // Response complete
  res.on('end', () => {
    console.log('Status:', res.statusCode);
    console.log('Headers:', res.headers);

    try {
      const users = JSON.parse(data);
      console.log('Users:', users);
    } catch (err) {
      console.error('Parse error:', err.message);
    }
  });

}).on('error', (err) => {
  console.error('Request failed:', err.message);
});

http.get Key Points

  • http.get() calls req.end() automatically โ€” no need to call it manually
  • The callback receives an http.IncomingMessage (same type as server req)
  • The response is a Readable stream โ€” listen for 'data' and 'end'
  • Always handle 'error' events on the request object

HTTP Status Code Handling

function fetchJSON(url) {
  return new Promise((resolve, reject) => {
    http.get(url, (res) => {
      const { statusCode, headers } = res;

      // Handle redirects
      if (statusCode >= 300 && statusCode < 400 && headers.location) {
        return resolve(fetchJSON(headers.location));
      }

      // Handle errors
      if (statusCode < 200 || statusCode >= 300) {
        reject(new Error(`HTTP ${statusCode}: ${statusMessage}`));
        res.resume(); // Consume response to free memory
        return;
      }

      // Collect body
      const chunks = [];
      res.on('data', chunk => chunks.push(chunk));
      res.on('end', () => {
        try {
          resolve(JSON.parse(Buffer.concat(chunks).toString()));
        } catch (err) {
          reject(new Error('Invalid JSON response'));
        }
      });
    }).on('error', reject);
  });
}

// Usage
try {
  const data = await fetchJSON('https://api.github.com/users/octocat');
  console.log('User:', data.login, '-', data.name);
} catch (err) {
  console.error('Failed:', err.message);
}

2. http.request โ€” Full Control

http.request gives you complete control over the request โ€” method, headers, body, and more:

const http = require('http');

function makeRequest(options, body = null) {
  return new Promise((resolve, reject) => {
    const req = http.request(options, (res) => {
      const chunks = [];

      res.on('data', (chunk) => chunks.push(chunk));
      res.on('end', () => {
        const data = Buffer.concat(chunks);

        resolve({
          statusCode: res.statusCode,
          statusMessage: res.statusMessage,
          headers: res.headers,
          body: data.toString(),
          json: () => JSON.parse(data.toString()),
        });
      });
    });

    req.on('error', reject);
    req.setTimeout(10000, () => {
      req.destroy();
      reject(new Error('Request timed out'));
    });

    if (body) {
      req.write(body);
    }

    req.end(); // Must call end() to send the request
  });
}

GET Request

const response = await makeRequest({
  hostname: 'api.example.com',
  port: 443,
  path: '/users?page=1',
  method: 'GET',
  headers: {
    'Accept': 'application/json',
    'User-Agent': 'Node.js Client/1.0',
  },
});

console.log('Status:', response.statusCode);
console.log('Body:', response.body);

POST Request

const postData = JSON.stringify({
  name: 'Alice',
  email: 'alice@example.com',
});

const response = await makeRequest({
  hostname: 'api.example.com',
  port: 443,
  path: '/users',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(postData),
    'Authorization': 'Bearer my-token',
  },
}, postData);

if (response.statusCode === 201) {
  const user = response.json();
  console.log('Created user:', user);
}

PUT Request

const updateData = JSON.stringify({ name: 'Updated Name' });

const response = await makeRequest({
  hostname: 'api.example.com',
  path: '/users/42',
  method: 'PUT',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(updateData),
  },
}, updateData);

DELETE Request

const response = await makeRequest({
  hostname: 'api.example.com',
  path: '/users/42',
  method: 'DELETE',
});

3. http.request Options Object

The first argument to http.request can be:

a) URL string or URL object

http.request('http://api.example.com/users', callback);
http.request(new URL('http://api.example.com/users'), callback);

b) Options object

const options = {
  // URL components
  hostname: 'api.example.com',    // Host without port
  port: 443,                       // Default: 80 for http, 443 for https
  path: '/users?page=1',           // Path + query string
  method: 'GET',                   // Default: GET

  // Authentication
  auth: 'username:password',        // Basic auth (sent as header)

  // Headers
  headers: {
    'Content-Type': 'application/json',
    'User-Agent': 'MyApp/1.0',
  },

  // Timeouts
  timeout: 5000,                    // Socket timeout in ms

  // Connection management
  keepAlive: true,                  // Reuse connections (HTTP/1.1)
  keepAliveMsecs: 1000,            // Initial keep-alive delay

  // Socket options
  family: 4,                        // IPv4 (4) or IPv6 (6)
  localAddress: '192.168.1.100',   // Bind to specific IP

  // TLS/SSL (https module)
  cert: fs.readFileSync('cert.pem'),
  key: fs.readFileSync('key.pem'),
  rejectUnauthorized: true,        // Validate server cert
};

4. HTTPS

For HTTPS requests, use the https module instead:

const https = require('https');
const http = require('http');

function createClient(urlStr) {
  const url = new URL(urlStr);
  const mod = url.protocol === 'https:' ? https : http;

  return {
    request(options, body) {
      return new Promise((resolve, reject) => {
        const mergedOptions = {
          hostname: url.hostname,
          port: url.port || (url.protocol === 'https:' ? 443 : 80),
          path: url.pathname + url.search,
          ...options,
        };

        const req = mod.request(mergedOptions, (res) => {
          const chunks = [];
          res.on('data', c => chunks.push(c));
          res.on('end', () => resolve({
            statusCode: res.statusCode,
            headers: res.headers,
            body: Buffer.concat(chunks).toString(),
          }));
        });

        req.on('error', reject);
        if (body) req.write(body);
        req.end();
      });
    },
  };
}

// Usage
const client = createClient('https://api.github.com');
const response = await client.request({
  path: '/users/octocat',
  headers: { 'User-Agent': 'Node.js' },
});
console.log(response.body);

5. The fetch API (Node.js 18+)

Node.js 18 introduced a global fetch based on undici:

// No import needed โ€” it's global

// GET
const response = await fetch('https://api.github.com/users/octocat');
const data = await response.json();
console.log(data.login);

// POST
const createResponse = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123',
  },
  body: JSON.stringify({ name: 'Alice' }),
});

if (createResponse.ok) {
  const user = await createResponse.json();
  console.log('Created:', user);
}

Advanced Fetch Patterns

// Error handling
async function fetchWithErrorHandling(url, options = {}) {
  try {
    const response = await fetch(url, {
      ...options,
      signal: AbortSignal.timeout(5000), // 5 second timeout
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`HTTP ${response.status}: ${errorBody.slice(0, 200)}`);
    }

    return response;
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error('Request timed out');
    }
    throw err;
  }
}

// Form data (Node.js 20+)
const form = new FormData();
form.append('username', 'alice');
form.append('avatar', new Blob([buffer]), 'photo.jpg');

const formResponse = await fetch('https://api.example.com/upload', {
  method: 'POST',
  body: form, // Content-Type is set automatically with boundary
});

Abort Controller โ€” Cancelling Requests

function fetchWithTimeout(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);

  return fetch(url, { signal: controller.signal })
    .finally(() => clearTimeout(timeout));
}

// Or use AbortSignal.timeout() (Node.js 19+)
const response = await fetch(url, {
  signal: AbortSignal.timeout(5000),
});

6. Streaming Responses (All Methods)

All HTTP client methods support streaming โ€” useful for large responses:

const http = require('http');
const fs = require('fs');
const { pipeline } = require('stream/promises');

// Download a large file without buffering in memory
async function downloadFile(url, destPath) {
  return new Promise((resolve, reject) => {
    http.get(url, async (response) => {
      if (response.statusCode !== 200) {
        reject(new Error(`HTTP ${response.statusCode}`));
        response.resume(); // Drain the response
        return;
      }

      const totalSize = parseInt(response.headers['content-length'], 10);
      let downloaded = 0;

      // Track progress
      response.on('data', (chunk) => {
        downloaded += chunk.length;
        const percent = ((downloaded / totalSize) * 100).toFixed(1);
        process.stdout.write(`\rDownloading: ${percent}%`);
      });

      await pipeline(response, fs.createWriteStream(destPath));
      console.log('\nDownload complete:', destPath);
      resolve();
    }).on('error', reject);
  });
}

7. Retry with Exponential Backoff

Network calls fail. Retry intelligently:

async function fetchWithRetry(url, options = {}) {
  const {
    retries = 3,
    baseDelay = 1000,
    maxDelay = 10000,
    shouldRetry = (response) => response.status >= 500 || response.status === 429,
  } = options;

  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url);

      if (response.ok) return response;

      if (shouldRetry(response) && attempt < retries) {
        const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
        console.warn(`Attempt ${attempt} returned ${response.status}. Retrying in ${delay}ms...`);
        await new Promise(r => setTimeout(r, delay));
        continue;
      }

      // Non-retryable error or last attempt
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    } catch (err) {
      if (err.name === 'AbortError') throw err;
      if (attempt === retries) throw err;

      // Network error โ€” retry
      const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
      console.warn(`Attempt ${attempt} failed: ${err.message}. Retrying in ${delay}ms...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

8. Connection Pooling (Keep-Alive)

By default, Node.js creates a new TCP connection for each request. Enable keep-alive for reuse:

const http = require('http');
const { Agent } = require('http');

// Create a connection pool
const agent = new Agent({
  keepAlive: true,
  keepAliveMsecs: 1000,
  maxSockets: 50,        // Max concurrent connections per host
  maxFreeSockets: 10,    // Max idle sockets to keep open
  timeout: 30000,        // Socket inactivity timeout
});

async function makeRequest(path) {
  // All requests through this agent share connections
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: 'api.example.com',
      path,
      agent,  // Share the agent
    }, (res) => {
      let data = '';
      res.on('data', c => data += c);
      res.on('end', () => resolve(data));
    });
    req.on('error', reject);
    req.end();
  });
}

// Make 100 requests โ€” they share TCP connections
const promises = Array.from({ length: 100 }, (_, i) =>
  makeRequest(`/items/${i}`)
);
const results = await Promise.all(promises);

Practical: API Client Class

const https = require('https');

class APIClient {
  constructor(baseURL, options = {}) {
    this.baseURL = new URL(baseURL);
    this.headers = {
      'Content-Type': 'application/json',
      'User-Agent': 'APIClient/1.0',
      ...options.headers,
    };
    this.timeout = options.timeout || 10000;
  }

  async request(method, path, body = null) {
    const url = new URL(path, this.baseURL);

    return new Promise((resolve, reject) => {
      const req = https.request(
        {
          hostname: url.hostname,
          port: url.port,
          path: url.pathname + url.search,
          method,
          headers: {
            ...this.headers,
            ...(body ? { 'Content-Length': Buffer.byteLength(body) } : {}),
          },
          timeout: this.timeout,
        },
        (res) => {
          const chunks = [];
          res.on('data', c => chunks.push(c));
          res.on('end', () => {
            const data = Buffer.concat(chunks).toString();

            if (res.statusCode >= 400) {
              reject(new APIError(
                res.statusCode,
                res.statusMessage,
                data,
              ));
              return;
            }

            resolve({
              status: res.statusCode,
              headers: res.headers,
              data: data ? JSON.parse(data) : null,
            });
          });
        },
      );

      req.on('error', reject);
      req.on('timeout', () => {
        req.destroy();
        reject(new Error('Request timeout'));
      });

      if (body) req.write(body);
      req.end();
    });
  }

  // Convenience methods
  get(path)          { return this.request('GET', path); }
  post(path, data)   { return this.request('POST', path, JSON.stringify(data)); }
  put(path, data)    { return this.request('PUT', path, JSON.stringify(data)); }
  delete(path)       { return this.request('DELETE', path); }
}

class APIError extends Error {
  constructor(status, message, body) {
    super(`${status}: ${message}`);
    this.status = status;
    this.body = body;
  }
}

// Usage
const client = new APIClient('https://jsonplaceholder.typicode.com');

const posts = await client.get('/posts');
console.log('Posts:', posts.data.slice(0, 2));

const newPost = await client.post('/posts', {
  title: 'My Post',
  body: 'Content here',
  userId: 1,
});
console.log('Created:', newPost.data);

Key Takeaways

  • http.get() โ€” simplest for GET requests, calls req.end() automatically
  • http.request() โ€” full control over method, headers, body โ€” requires manual req.end()
  • Always handle errors โ€” network failures, timeouts, DNS errors, and non-2xx status codes
  • Set timeouts โ€” req.setTimeout() or AbortSignal.timeout() prevents hanging requests
  • Check res.statusCode โ€” redirects (3xx), errors (4xx, 5xx), success (2xx)
  • Stream large responses โ€” use pipeline(response, fs.createWriteStream()) for files
  • Use keep-alive agents for connection reuse when making many requests to the same host
  • fetch() (Node 18+) is the modern, promise-based alternative โ€” prefer it for new code
  • Implement retry with exponential backoff for flaky networks or rate-limited APIs
  • Build an API client class to encapsulate base URL, headers, auth, and error handling