How to send an HTTP request in Node.js

How to send an HTTP request in Node.js

HTTP requests are the backbone of client-server communication on the web. At its core, an HTTP request is just a structured message sent by the client to a server, asking for a resource or submitting data. Understanding the components of these requests is important when you want to interact directly with web services or build your own.

Every HTTP request consists of a method, a URL, headers, and sometimes a body. The method tells the server what kind of action you want to perform. The most common are GET (retrieve data), POST (send data), PUT (update data), DELETE (remove data). The URL specifies the resource you are targeting. Headers carry metadata like authentication tokens, content type, or caching instructions. The body, present in methods like POST and PUT, carries the payload—data you want to send.

When you open your browser and type a URL, the browser sends a GET request behind the scenes. But when you’re programming, you often want to control these parts explicitly. The protocol itself is text-based, which means you could craft HTTP requests by hand if you wanted to, but that’s rarely practical.

Here’s a raw HTTP GET request example that fetches the homepage of example.com:

GET / HTTP/1.1
Host: example.com
User-Agent: CustomClient/1.0
Accept: text/html

The server responds with a status line like HTTP/1.1 200 OK, followed by headers and the body content. The status code signals success or failure, with 2xx codes indicating success, 4xx client errors, and 5xx server errors.

Understanding this structure helps when debugging or when you want precise control over what you send and receive. For instance, adding an Authorization header can be as simple as including a line like:

Authorization: Bearer YOUR_ACCESS_TOKEN

which tells the server who you’re and what permissions you have.

On the wire, the HTTP request is just bytes sent over a TCP connection. The client opens a socket, writes the request text, waits for the response, and then reads it back. That is what libraries and frameworks abstract away, but knowing this lets you troubleshoot issues that might otherwise feel magical.

When you move to HTTPS, the connection is encrypted, but the request and response format stays the same; only the transport layer changes under the hood.

Let’s see how this translates when you want to implement a simple HTTP GET from scratch using Node’s built-in net module, which gives you raw TCP sockets. This example connects to example.com on port 80, sends a GET request, and prints the response:

const net = require('net');

const client = net.createConnection({ host: 'example.com', port: 80 }, () => {
  client.write("GET / HTTP/1.1rnHost: example.comrnConnection: closernrn");
});

client.on('data', (data) => {
  console.log(data.toString());
});

client.on('end', () => {
  console.log('Disconnected from server');
});

This example bypasses HTTP libraries altogether, showing the raw exchange. It’s a good way to get a feel for how HTTP is just text over TCP, but it’s not how you’d write production code—more a learning tool.

Headers and body are separated by a blank line (two CRLFs). The client specifies Connection: close to tell the server to close the connection after the response, simplifying client-side logic.

Parsing the response yourself is tricky because the server uses headers to signal how the body is encoded—like Content-Length or Transfer-Encoding: chunked. Handling these correctly is why libraries exist.

You can experiment with other methods by changing the request line. For example, to POST data, you’d send:

POST /submit HTTP/1.1
Host: example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 13

name=paul

Here you must include content-length so the server knows how many bytes to read after the headers.

Understanding these basics—methods, headers, body, status codes, and the TCP transport—is the foundation for everything HTTP-related. Once you grasp this, using higher-level APIs becomes intuitive rather than black magic.

Using built-in modules for making requests

Node.js ships with a built-in module called http that abstracts away the raw TCP details but still gives you full control over the request. It handles connection management, header parsing, chunked encoding, and more—letting you focus on the semantics.

For a simple GET request, you use http.get(), which is a convenience method wrapping http.request() with the method preset to GET. Here’s how you fetch the homepage of example.com:

const http = require('http');

http.get('http://example.com', (res) => {
  console.log(Status: ${res.statusCode});
  console.log('Headers:', res.headers);

  res.setEncoding('utf8');
  let rawData = '';
  res.on('data', (chunk) => { rawData += chunk; });
  res.on('end', () => {
    console.log('Body:', rawData);
  });
}).on('error', (e) => {
  console.error(Got error: ${e.message});
});

Notice how you receive a res object, which is a readable stream. You listen for data events to accumulate the body, and the end event signals the response is complete. This pattern is common in Node.js for streamed data.

If you want to make a POST request or customize headers, you use http.request() directly. It’s a bit more verbose but necessary for anything beyond GET. Here’s an example POST with JSON data:

const data = JSON.stringify({ name: 'paul', role: 'developer' });

const options = {
  hostname: 'example.com',
  port: 80,
  path: '/api/users',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(data),
  },
};

const req = http.request(options, (res) => {
  console.log(Status: ${res.statusCode});
  res.setEncoding('utf8');
  let body = '';
  res.on('data', (chunk) => { body += chunk; });
  res.on('end', () => {
    console.log('Response:', body);
  });
});

req.on('error', (e) => {
  console.error(Problem with request: ${e.message});
});

req.write(data);
req.end();

Here you explicitly call req.write() to send the request body before ending the request with req.end(). The headers must include Content-Length so the server knows when the body ends.

For HTTPS requests, you swap the http module with the https module; the API is nearly identical. This modularity means you can switch protocols without rewriting your request logic.

One subtlety is handling redirects. The built-in modules don’t follow redirects automatically—you get the 3xx response and must manually issue a new request to the Location header. This is a common reason to reach for higher-level libraries.

Lastly, the built-in modules give you access to the raw socket if needed. For example, you can listen for the socket event on the request to get the underlying net.Socket instance. That is useful if you want to tweak timeouts or monitor connection state:

const req = http.request(options, (res) => {
  // handle response
});

req.on('socket', (socket) => {
  socket.setTimeout(5000);
  socket.on('timeout', () => {
    console.error('Socket timeout');
    req.abort();
  });
});

req.end();

This shows how close you can get to the network layer while still using the built-in HTTP abstractions. It’s a good balance of control and convenience, but the API can feel verbose compared to modern alternatives, especially when dealing with JSON and promises.

Using third-party libraries for simplicity

Third-party libraries exist to smooth over the rough edges of the built-in modules, making HTTP requests simpler, more readable, and often promise-based. They handle redirects, parsing, error handling, and content negotiation for you, so you can focus on what your code should do rather than the mechanics of HTTP.

One of the most popular libraries in the Node.js ecosystem is axios. It’s a promise-based HTTP client that works both in Node.js and browsers, providing a consistent API. Here’s how you’d fetch the homepage of example.com using axios:

const axios = require('axios');

axios.get('http://example.com')
  .then(response => {
    console.log('Status:', response.status);
    console.log('Headers:', response.headers);
    console.log('Body:', response.data);
  })
  .catch(error => {
    console.error('Error:', error.message);
  });

Notice how the response body is directly available as response.data, and the entire flow uses promises, which integrate nicely with async/await syntax.

Using async/await, the same request looks cleaner:

async function fetchHomepage() {
  try {
    const response = await axios.get('http://example.com');
    console.log('Status:', response.status);
    console.log('Headers:', response.headers);
    console.log('Body:', response.data);
  } catch (error) {
    console.error('Error:', error.message);
  }
}

fetchHomepage();

For POST requests, axios automatically stringifies JSON and sets appropriate headers:

async function createUser() {
  try {
    const response = await axios.post('http://example.com/api/users', {
      name: 'paul',
      role: 'developer'
    });
    console.log('Status:', response.status);
    console.log('Response:', response.data);
  } catch (error) {
    console.error('Error:', error.message);
  }
}

createUser();

axios also supports request cancellation, interceptors for request and response transformations, and automatic handling of redirects and gzip compression out of the box. This reduces boilerplate and edge-case handling.

Another widely used library is node-fetch, a Node.js implementation of the Fetch API familiar from the browser. It’s minimal and promise-based, making it a good choice if you prefer the fetch semantics:

const fetch = require('node-fetch');

async function fetchData() {
  const response = await fetch('http://example.com');
  console.log('Status:', response.status);
  console.log('Headers:', response.headers.raw());

  const text = await response.text();
  console.log('Body:', text);
}

fetchData().catch(console.error);

With node-fetch, you manually decide how to parse the body—text(), json(), or buffer(). This explicitness can be useful when you want precise control over data formats.

For POST requests with JSON, the syntax is straightforward:

async function postData() {
  const response = await fetch('http://example.com/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name: 'paul', role: 'developer' }),
  });

  const data = await response.json();
  console.log('Status:', response.status);
  console.log('Response:', data);
}

postData().catch(console.error);

Unlike the built-in http module, these libraries abstract away the manual setting of Content-Length and the complexities of stream handling. They also handle common pitfalls like automatic redirection, encoding, and error propagation.

Other libraries worth mentioning include superagent, which provides a flexible and chainable API, and got, which is a powerful and modern HTTP client with retries, hooks, and streaming support.

Here’s a quick example using got to perform a GET request with timeout and retries:

const got = require('got');

(async () => {
  try {
    const response = await got('http://example.com', {
      timeout: 5000,
      retry: 2
    });
    console.log('Status:', response.statusCode);
    console.log('Body:', response.body);
  } catch (error) {
    console.error('Request failed:', error.message);
  }
})();

These libraries often come with built-in support for features that you’d otherwise have to implement yourself: cookie handling, proxy support, HTTP/2, and more. They are the tools that let you treat HTTP requests as a simple function call rather than a sequence of socket events.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *