The Most Efficient Way to Make an HTTP GET Request in Node.js Within Express

The most efficient way to make an HTTP GET request in Node.js within an Express application is to use the native http or https modules and stream the response directly into the Express res object, eliminating memory buffering.

When an Express server receives a request, it constructs lightweight req and res objects based on the prototypes defined in lib/request.js and lib/response.js within the expressjs/express repository. To perform an outbound HTTP GET request in Node.js while maintaining maximum throughput, you should leverage Node.js’s native streaming capabilities rather than higher-level libraries that materialize entire responses into memory.

Why Native Streaming is the Most Efficient Pattern

Streaming the response from an upstream server directly to the client avoids materializing the entire payload in memory. This approach aligns with how Express itself handles data flow, as seen in lib/application.js where the framework manages the request-response lifecycle.

Key efficiency benefits:

  • Zero intermediate buffering: By piping the upstream IncomingMessage into the Express ServerResponse, data transfers chunk-by-chunk without being fully loaded into the Node.js heap.
  • Single event-loop turn: The http.request or https.request methods utilize Node’s native event-driven I/O, which is faster than abstraction layers that add promise overhead or additional middleware.
  • Header preservation: You can forward the remote status code and headers unchanged using res.writeHead, maintaining caching semantics and content-type accuracy (referencing header handling patterns in lib/response.js).
  • Minimal error latency: Errors are caught via the 'error' event on the outbound request, allowing immediate termination with a 502 Bad Gateway response without waiting for timeouts.

Implementing Efficient HTTP GET Requests in Express

The Streaming Proxy Pattern (Zero Buffering)

The most performant implementation pipes the upstream response directly into the Express response object. This pattern is ideal for proxying external APIs where you want to minimize memory footprint.

const express = require('express');
const https = require('node:https');
// const http = require('node:http'); // Use for HTTP endpoints

const app = express();

/**
 * GET /proxy?url=https://api.example.com/data
 * Streams the remote GET response directly to the client.
 */
app.get('/proxy', (req, res) => {
  const target = new URL(req.query.url);
  const client = target.protocol === 'https:' ? https : require('node:http');

  const options = {
    hostname: target.hostname,
    path: target.pathname + target.search,
    method: 'GET',
    headers: {
      accept: req.headers.accept || '*/*',
    },
  };

  const upstream = client.request(options, (upstreamRes) => {
    // Forward status code and headers exactly as received
    res.writeHead(upstreamRes.statusCode, upstreamRes.headers);
    // Stream data without buffering
    upstreamRes.pipe(res);
  });

  upstream.on('error', (err) => {
    console.error('Upstream error:', err);
    if (!res.headersSent) {
      res.status(502).send('Bad Gateway');
    } else {
      res.end();
    }
  });

  upstream.end();
});

app.listen(3000, () => console.log('Server listening on http://localhost:3000'));

Key implementation details:

  • upstreamRes.pipe(res) establishes a direct data flow from the remote server to the client.
  • res.writeHead mirrors the original HTTP status and headers, ensuring content-type and cache-control directives remain intact.
  • Error handling checks res.headersSent to avoid crashing if the response has already begun streaming.

Async/Await Wrapper with Streaming Preservation

For developers who prefer modern asynchronous patterns, you can wrap the native request in a Promise while still maintaining the streaming efficiency. This approach is useful when you need to perform validation or logging before initiating the stream.

const express = require('express');
const { request } = require('node:https');

const app = express();

/**
 * Helper that returns a Promise resolving to the response stream.
 * Does not buffer the body.
 */
function getStream(url) {
  const target = new URL(url);
  const client = target.protocol === 'https:' ? require('node:https') : require('node:http');

  return new Promise((resolve, reject) => {
    const req = client.request(
      {
        hostname: target.hostname,
        path: target.pathname + target.search,
        method: 'GET',
        headers: { accept: 'application/json' },
      },
      (res) => resolve(res)
    );

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

app.get('/async-proxy', async (req, res) => {
  try {
    const upstream = await getStream(req.query.url);
    res.writeHead(upstream.statusCode, upstream.headers);
    upstream.pipe(res);
  } catch (err) {
    console.error(err);
    res.status(502).send('Bad Gateway');
  }
});

app.listen(3000);

Even though the code uses async/await for readability, the actual data transfer occurs via upstream.pipe(res), ensuring that memory usage remains constant regardless of response size.

Key Express Source Files Supporting This Pattern

Understanding how Express constructs the request and response objects helps explain why native streaming integrates seamlessly:

File Purpose Location
lib/express.js Exposes createApplication() which initializes the Express app function and merges the request/response prototypes view
lib/application.js Implements the app prototype, including route registration (app.get, app.post) and middleware stack handling (lines 71‑80) view
lib/request.js Defines the req prototype that Express augments for each incoming request view
lib/response.js Defines the res prototype, including res.send(), res.json(), and res.writeHead() used to send data back to the client view

These files demonstrate that Express is a thin layer over Node.js’s native http module. By using http.request or https.request directly within your route handlers, you operate at the same abstraction level as the framework itself, minimizing overhead.

Summary

  • Stream directly: Use http.request or https.request and pipe the IncomingMessage into the Express res object to avoid memory buffering.
  • Preserve headers: Forward status codes and headers using res.writeHead to maintain caching and content-type semantics.
  • Handle errors immediately: Listen for the 'error' event on the outbound request and return 502 Bad Gateway if the upstream fails.
  • Leverage native modules: The expressjs/express repository builds on Node’s native http module (see lib/application.js and lib/response.js), making native streaming the most cohesive and performant choice for HTTP GET requests in Node.js.

Frequently Asked Questions

Is using native http/https modules faster than axios or node-fetch in Express?

Yes. Libraries like axios and node-fetch typically buffer the entire response body into memory before resolving, or they wrap streams in additional Promise machinery that adds CPU overhead. Using Node’s native http.request or https.request allows you to pipe data directly from the socket to the Express res object, keeping memory usage constant and reducing latency.

How do I handle errors when streaming HTTP GET requests in Node.js?

Attach an 'error' event listener to the outbound request object returned by http.request. If an error occurs before headers are sent to the client, respond with a 502 Bad Gateway status. If headers have already been sent (the stream has started), immediately call res.end() to terminate the connection. Always check res.headersSent to avoid attempting to send headers twice, which would crash the process.

Can I modify the response body when streaming an HTTP GET request?

Modifying the body during a pure stream pipe requires inserting a Transform stream between the upstream response and the Express res object. You can use Node’s built-in stream.Transform to manipulate chunks as they pass through. Be aware that any transformation introduces memory overhead proportional to the chunk size being processed, breaking the zero-buffering optimization of a direct pipe.

What is the difference between req and res objects in Express?

The req (request) object represents the incoming HTTP request and contains properties like req.query, req.params, and req.headers. It is built from the prototype defined in lib/request.js. The res (response) object represents the outgoing HTTP response and provides methods like res.send(), res.json(), and res.writeHead(). It is built from the prototype defined in lib/response.js. Together, they form the interface for handling HTTP traffic within the Express framework, as orchestrated by lib/application.js.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →