Send HTTP Requests in Node.js Without Using an External Library

Understand the importance of mastering core HTTP request handling without relying on external libraries. This comprehensive guide will show you how to implement robust HTTP/HTTPS requests using Node.js native modules while optimizing for SEO performance to rank #1 on Google search results.

Sending GET Requests: The Professional Approach

When fetching data from APIs, the https.get method is your go-to solution. Here’s an enterprise-grade implementation:

const https = require('https');

/**
 * Fetches data from a JSON API endpoint
 * @param {string} url - The API endpoint URL
 * @param {Object} [headers={}] - Additional request headers
 * @returns {Promise<Object>} - Parsed JSON response
 */
async function fetchJsonData(url, headers = {}) {
  return new Promise((resolve, reject) => {
    https.get(url, { headers }, (res) => {
      const { statusCode } = res;
      const contentType = res.headers['content-type'];
      
      // Validate response
      if (statusCode !== 200) {
        res.resume(); // Consume response to free up memory
        return reject(new Error(`Request Failed. Status Code: ${statusCode}`));
      }
      
      if (!/^application\/json/.test(contentType)) {
        res.resume();
        return reject(new Error('Invalid content-type. Expected application/json'));
      }
      
      let rawData = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => rawData += chunk);
      res.on('end', () => {
        try {
          const parsedData = JSON.parse(rawData);
          resolve(parsedData);
        } catch (e) {
          reject(new Error('Error parsing JSON response'));
        }
      });
    }).on('error', (err) => {
      reject(new Error(`HTTPS request error: ${err.message}`));
    });
  });
}

// Usage example
(async () => {
  try {
    const posts = await fetchJsonData('https://jsonplaceholder.typicode.com/posts');
    console.log('Fetched posts:', posts.slice(0, 2)); // Log first 2 items
  } catch (error) {
    console.error('Fetch error:', error.message);
  }
})();

Key Optimizations:

  • Promise-based implementation for modern async/await syntax
  • Comprehensive error handling and response validation
  • Proper resource management with res.resume()
  • Content-type verification
  • Structured error messages

POST Requests: Advanced Implementation

For sending data, the https.request method provides full control. Here’s an optimized version:

const https = require('https');

/**
 * Sends a POST request with JSON payload
 * @param {string} hostname - Target hostname
 * @param {string} path - API endpoint path
 * @param {Object} data - Payload data
 * @param {Object} [customHeaders={}] - Additional headers
 * @returns {Promise<Object>} - Parsed JSON response
 */
async function postJsonData(hostname, path, data, customHeaders = {}) {
  const postData = JSON.stringify(data);
  
  const headers = {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(postData),
    'Accept': 'application/json',
    ...customHeaders
  };

  const options = {
    hostname,
    port: 443,
    path,
    method: 'POST',
    headers
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      const { statusCode } = res;
      let rawData = '';
      
      res.setEncoding('utf8');
      res.on('data', (chunk) => rawData += chunk);
      res.on('end', () => {
        try {
          if (statusCode >= 400) {
            throw new Error(`Request failed with status code ${statusCode}`);
          }
          resolve(rawData ? JSON.parse(rawData) : {});
        } catch (error) {
          reject(error);
        }
      });
    });

    req.on('error', (error) => {
      reject(error);
    });

    req.write(postData);
    req.end();
  });
}

// Usage example
(async () => {
  try {
    const newPost = await postJsonData(
      'jsonplaceholder.typicode.com',
      '/posts',
      { title: 'foo', body: 'bar', userId: 1 }
    );
    console.log('Created post:', newPost);
  } catch (error) {
    console.error('POST error:', error.message);
  }
})();

Enterprise Features:

  • Modular, reusable function design
  • Automatic JSON serialization
  • Comprehensive status code handling
  • Header merging for custom headers
  • Proper content-length calculation
  • Empty response handling

Handling Complex Content Types

For form submissions and multipart data, here’s a robust solution:

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

/**
 * Sends URL-encoded form data
 * @param {Object} config - Request configuration
 * @param {string} config.hostname - Target hostname
 * @param {string} config.path - Endpoint path
 * @param {Object} config.formData - Key-value form data
 * @param {Object} [config.headers={}] - Additional headers
 * @returns {Promise<string>} - Raw response string
 */
async function postFormData({ hostname, path, formData, headers = {} }) {
  const postData = querystring.stringify(formData);
  
  const requestHeaders = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Content-Length': Buffer.byteLength(postData),
    ...headers
  };

  const options = {
    hostname,
    port: 443,
    path,
    method: 'POST',
    headers: requestHeaders
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      let responseData = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => responseData += chunk);
      res.on('end', () => resolve(responseData));
    });

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

// Usage example
(async () => {
  try {
    const response = await postFormData({
      hostname: 'example.com',
      path: '/submit',
      formData: { key1: 'value1', key2: 'value2' }
    });
    console.log('Form submission response:', response);
  } catch (error) {
    console.error('Form submission error:', error.message);
  }
})();

Production-Grade Error Handling and Timeouts

For mission-critical applications, implement these robust error handling patterns:

const https = require('https');

/**
 * Request with timeout and retry logic
 * @param {Object} options - HTTPS request options
 * @param {string|Object} postData - Data to send
 * @param {number} [timeout=5000] - Timeout in milliseconds
 * @param {number} [retries=1] - Number of retry attempts
 * @returns {Promise<Object>} - Parsed response
 */
async function robustRequest(options, postData, timeout = 5000, retries = 1) {
  let lastError;
  
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      return await new Promise((resolve, reject) => {
        const req = https.request(options, (res) => {
          let rawData = '';
          res.setEncoding('utf8');
          res.on('data', (chunk) => rawData += chunk);
          res.on('end', () => {
            try {
              resolve(rawData ? JSON.parse(rawData) : {});
            } catch (error) {
              reject(new Error('Response parsing failed'));
            }
          });
        });

        // Timeout handling
        req.setTimeout(timeout, () => {
          req.destroy();
          reject(new Error(`Request timed out after ${timeout}ms`));
        });

        req.on('error', (error) => {
          // Handle specific errors
          if (error.code === 'ECONNRESET') {
            reject(new Error('Connection reset by server'));
          } else {
            reject(error);
          }
        });

        if (postData) {
          req.write(typeof postData === 'object' 
            ? JSON.stringify(postData) 
            : postData);
        }
        req.end();
      });
    } catch (error) {
      lastError = error;
      if (attempt < retries) {
        await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); // Exponential backoff
      }
    }
  }
  
  throw lastError || new Error('Max retries exceeded');
}

// Usage example
(async () => {
  try {
    const result = await robustRequest(
      {
        hostname: 'jsonplaceholder.typicode.com',
        path: '/posts',
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }
      },
      { title: 'Retry Test', body: 'This will retry if needed', userId: 1 },
      3000, // 3s timeout
      2     // 2 retries
    );
    console.log('Success after retries:', result);
  } catch (error) {
    console.error('Final error after retries:', error.message);
  }
})();

Advanced Error Handling Features:

  • Configurable timeout with proper cleanup
  • Exponential backoff retry strategy
  • Specific error code handling
  • Proper request destruction on timeout
  • Comprehensive error reporting

Native Modules vs. Libraries: The Senior Engineer’s Perspective

As an experienced Node.js developer, you know when to choose native modules over libraries:

Use Native Modules When:

  • You need to minimize dependencies (security-critical applications)
  • You require absolute control over request lifecycle
  • Your project has strict bundle size constraints
  • You’re building a library that shouldn’t dictate HTTP client choices
  • You need to support obscure protocols or custom TLS configurations

Consider Libraries When:

  • You need interceptors for logging/authentication
  • Automatic retries with sophisticated backoff strategies are required
  • You need built-in request/response transformation
  • Your application makes hundreds of concurrent requests
  • You need browser compatibility (isomorphic code)

Conclusion: The Path to Mastery

Mastering Node.js native HTTP/HTTPS modules is essential for senior engineers. While libraries offer convenience, understanding the underlying implementation gives you:

  1. Debugging Superpowers: You can fix issues that third-party libraries might obscure
  2. Performance Optimization: You can shave microseconds off requests by eliminating abstraction layers
  3. Security Control: You can implement custom security policies at the protocol level
  4. Maintenance Confidence: You won’t be affected by breaking changes in dependencies

For most applications, a hybrid approach works best: use native modules for core infrastructure and lightweight libraries for application code. This gives you the best of both worlds - control where you need it, and convenience where it makes sense.

Additional Resources

  1. Node.js HTTPS Module Deep Dive - Official documentation with advanced patterns
  2. HTTP/2 in Node.js - For modern protocol implementations
  3. TLS/SSL Hardening Guide - For production-grade security
  4. Performance Benchmarking Native vs. Libraries - Quantitative comparison
  5. Enterprise Patterns for HTTP Clients - Battle-tested architectures

By implementing these patterns in your Node.js applications, you’ll not only write more robust code but also create content that naturally ranks well in search engines through technical depth and comprehensive coverage of the topic.


Node.js HTTP request Send HTTP request without external library Node.js native HTTP module