PDF extraction

Build a Robust URL File Downloader: A Practical Guide

Learn to build a robust URL file downloader with Node.js and Python. This guide covers streaming, chunking, retries, security, and integrating with OkraPDF.

May 29, 2026 14 min read OkraPDF
url file downloaderfile download apinodejs downloaderpython downloaderpdf download

You probably need a file from a URL right now because some downstream job depends on it. Maybe it's a PDF your customer pasted into your app. Maybe it's a signed link from a vendor portal. Maybe it's a “download” URL that only works in a browser tab and falls apart the second your backend touches it.

That gap is where most URL downloader guides stop being useful. They show a one-liner, the happy path works once, and then production traffic finds every sharp edge: redirects, expiring query strings, HTML pages pretending to be files, partial responses, memory blowups, and security problems you definitely don't want in your ingest service.

A production-grade URL file downloader is not a single request. It's a pipeline with URL normalization, streaming, retries, integrity checks, and enough observability to explain why a file failed.

Table of Contents

Why Simple Fetch Is Not Enough

The code everybody starts with looks like this:

const res = await fetch(url);
const buffer = await res.arrayBuffer();
await fs.promises.writeFile("file.pdf", Buffer.from(buffer));

That's fine for a toy script. It's bad for a service.

The first problem is memory. arrayBuffer() pulls the entire response into memory before you write a byte to disk. That's how small test files turn into crashed workers once someone pastes a large asset URL into your app.

The happy path lies

The second problem is that many file URLs aren't really file URLs. A lot of “download links” are one of these:

  • A redirect chain ending at temporary object storage
  • An HTML page with a button that triggers the actual file request
  • A generated link created only after JavaScript runs
  • An authenticated route that expects cookies or bearer tokens
  • A failure page that returns markup with 200 OK

That's not edge-case trivia. Guidance around non-direct file URLs is still thin, even though real-world examples often require scraping the page to extract the actual href first, especially when redirects, password-protected pages, or generated links are involved, as discussed in this walkthrough on handling non-direct downloads.

Practical rule: If a URL works in a browser but fails in automation, assume you're missing navigation, authentication, rendering, or redirect handling. Don't assume the remote server is wrong.

What a downloader is actually responsible for

A real downloader owns more than “send request, save file.” At minimum, it should answer these questions:

ConcernNaive fetchProduction downloader
Large filesBuffers in memoryStreams to disk or downstream service
Temporary failuresFails onceRetries selectively
RedirectsMaybeTracked and logged
HTML instead of fileOften missedDetected and rejected or resolved
Duplicate requestsCommonCanonicalized by normalized URL
SecurityOpen to abuseURL validation, limits, allowlists
DebuggingWeakRequest IDs, metadata, outcome logs

A downloader becomes infrastructure fast. Once other jobs depend on it, reliability matters more than cleverness.

You also need a different mindset for “works” versus “works safely.” A successful HTTP status doesn't mean you got the expected artifact. It might be a login screen, a rate-limit message, or a tiny error page with the wrong content type.

The Core Download Pipeline in Node.js and Python

The right baseline is a two-stage pipeline. First, normalize the URL and create a deterministic content key. Then fetch the content with a streaming client. That pattern mirrors a forensic scraping approach that used an MD5 hash of the URL for lookup and depended on deterministic URL handling to avoid duplicates from query strings or session-specific variants, as described in this URL normalization and hashing workflow.

A hand-drawn illustration showing data flowing from a server through Node.js and Python processes to a computer.

Stage one normalize and key the URL

Normalization is where you decide whether these are the same resource:

  • https://example.com/file.pdf
  • https://example.com/file.pdf?utm_source=x
  • https://example.com/file.pdf#page=2

Sometimes they are. Sometimes the query string is the whole identity. Don't strip parameters blindly. Build rules.

import crypto from "node:crypto";

export function normalizeUrl(input) {
  const url = new URL(input);

  url.hash = "";

  const removableParams = new Set([
    "utm_source",
    "utm_medium",
    "utm_campaign",
    "utm_term",
    "utm_content",
  ]);

  for (const key of [...url.searchParams.keys()]) {
    if (removableParams.has(key)) url.searchParams.delete(key);
  }

  url.protocol = url.protocol.toLowerCase();
  url.hostname = url.hostname.toLowerCase();

  if ((url.protocol === "https:" && url.port === "443") || (url.protocol === "http:" && url.port === "80")) {
    url.port = "";
  }

  return url.toString();
}

export function contentKeyFromUrl(normalizedUrl) {
  return crypto.createHash("md5").update(normalizedUrl).digest("hex");
}

A few rules matter:

  • Keep auth-bearing query params if the origin uses signed URLs.
  • Drop fragments because they usually affect client-side navigation, not the file bytes.
  • Normalize host casing and default ports so equivalent URLs hash the same way.

If you want a simpler PDF-only version first, this Node example for downloading a PDF is a decent starting point. Then come back and harden it.

Node.js streaming downloader

Use streams. Don't read whole files into memory unless you control the file size and don't mind the blast radius.

import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { pipeline } from "node:stream/promises";

export async function downloadToFile({
  url,
  destPath,
  headers = {},
  timeoutMs = 30000,
  maxRedirects = 5,
}) {
  await fs.promises.mkdir(path.dirname(destPath), { recursive: true });

  const response = await axios({
    method: "GET",
    url,
    headers,
    responseType: "stream",
    timeout: timeoutMs,
    maxRedirects,
    validateStatus: (status) => status >= 200 && status < 400,
  });

  const contentType = response.headers["content-type"] || "";
  const contentLength = response.headers["content-length"];

  if (contentType.includes("text/html")) {
    response.data.destroy();
    throw new Error(`Expected file response, got HTML: ${contentType}`);
  }

  const tempPath = `${destPath}.part`;
  const writer = fs.createWriteStream(tempPath);

  try {
    await pipeline(response.data, writer);
    await fs.promises.rename(tempPath, destPath);
  } catch (err) {
    await fs.promises.rm(tempPath, { force: true });
    throw err;
  }

  const stat = await fs.promises.stat(destPath);

  if (contentLength && Number(contentLength) !== stat.size) {
    throw new Error(`Size mismatch after download. header=${contentLength} actual=${stat.size}`);
  }

  return {
    path: destPath,
    size: stat.size,
    contentType,
    finalUrl: response.request?.res?.responseUrl || url,
  };
}

A few opinions from operating this kind of code:

  • Save to *.part first. Rename only after success.
  • Reject HTML early unless your workflow explicitly expects a page-resolution step.
  • Validate post-write size when Content-Length exists.
  • Log the final URL after redirects. That's often the actual answer when debugging.

Python streaming downloader

Python's requests still does the job well if you stream in chunks and keep the contract strict.

import os
import requests
from urllib.parse import urlparse

def download_to_file(url, dest_path, headers=None, timeout=30):
    headers = headers or {}
    os.makedirs(os.path.dirname(dest_path), exist_ok=True)
    temp_path = f"{dest_path}.part"

    with requests.get(url, headers=headers, stream=True, timeout=timeout, allow_redirects=True) as r:
        r.raise_for_status()

        content_type = r.headers.get("Content-Type", "")
        content_length = r.headers.get("Content-Length")

        if "text/html" in content_type.lower():
            raise ValueError(f"Expected file response, got HTML: {content_type}")

        with open(temp_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=1024 * 64):
                if chunk:
                    f.write(chunk)

    os.replace(temp_path, dest_path)

    actual_size = os.path.getsize(dest_path)
    if content_length is not None and int(content_length) != actual_size:
        raise ValueError(
            f"Size mismatch after download. header={content_length} actual={actual_size}"
        )

    return {
        "path": dest_path,
        "size": actual_size,
        "content_type": content_type,
        "final_url": r.url,
    }

A downloader should produce metadata, not just a file. You want final URL, content type, size, and failure reason in the same return value.

If you need browser execution for JavaScript-heavy sites, don't bolt it awkwardly onto the direct-fetch path. Make it a separate resolver stage that produces a concrete file URL, then send that into the streaming downloader. That keeps the core path simple.

Advanced Strategies for Large and Unreliable Files

A downloader that never retries is brittle. A downloader that retries everything is noisy and abusive. The trick is being selective.

A diagram illustrating the resilient file download process using retry logic and exponential backoff strategies.

Retry the right failures

Retry connection resets, transient timeouts, and some upstream failures. Don't retry malformed URLs, auth failures you know won't change, or content validation errors that prove the payload is wrong.

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function isRetriable(err) {
  const status = err.response?.status;
  if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;

  return [
    "ECONNRESET",
    "ETIMEDOUT",
    "EAI_AGAIN",
    "ECONNABORTED",
  ].includes(err.code);
}

export async function withRetry(fn, attempts = 4, baseDelayMs = 500) {
  let lastErr;

  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (!isRetriable(err) || i === attempts - 1) break;

      const delay = baseDelayMs * Math.pow(2, i);
      await sleep(delay);
    }
  }

  throw lastErr;
}

That backoff protects both sides. You avoid hammering a struggling origin, and your own workers don't thrash.

Resume instead of restarting

For large files, restarting from byte zero after a dropped connection is wasteful. If the origin supports range requests, resume from the last written offset.

import os
import requests

def resume_download(url, dest_path, headers=None, timeout=30):
    headers = headers or {}
    downloaded = os.path.getsize(dest_path) if os.path.exists(dest_path) else 0

    if downloaded > 0:
        headers["Range"] = f"bytes={downloaded}-"

    mode = "ab" if downloaded > 0 else "wb"

    with requests.get(url, headers=headers, stream=True, timeout=timeout) as r:
        if r.status_code not in (200, 206):
            r.raise_for_status()

        with open(dest_path, mode) as f:
            for chunk in r.iter_content(chunk_size=1024 * 64):
                if chunk:
                    f.write(chunk)

Three cautions:

  • Check server behavior. Some origins ignore Range and send a full 200 response.
  • Don't append blindly. If you asked for a range and got 200, restart from scratch.
  • Persist metadata. Save the expected URL, ETag if available, and current byte count alongside the partial file.

Operational shortcut: If resuming adds too much complexity for your first version, at least make failed downloads idempotent and restartable from a clean temp file.

Reduce latency before the request starts

When you know download origins in advance, connection setup matters. Akamai reports that 80% to 85% of website speed performance is influenced by page loading, and recommends DNS prefetching, preconnecting, prefetching, and fetchpriority to reduce latency on critical requests, while warning that over-preconnecting can be expensive and should be selective in this browser-side download performance guidance.

That matters most for browser-based download flows and frontends that trigger ingestion. If your UI knows the likely host, warming name resolution and connections can shave waiting time before the actual file request starts.

A simple decision rule helps:

  • Use DNS prefetch when the browser might need a host soon.
  • Use preconnect only for hosts you're confident the user will hit.
  • Use higher fetch priority for the request that unblocks the rest of the flow.
  • Avoid prewarming everything because extra sockets and lookups aren't free.

Securing Your File Download Workflow

A URL downloader is an inbound gateway to your system. Treat it like one.

If you accept arbitrary URLs from users and fetch them server-side, you've created an SSRF surface. If you let requests run too long or stream forever, you've created a resource exhaustion problem. If you trust the payload because the status code was good, you've created a bad day for every downstream parser.

A diagram illustrating a Secure Download Workflow consisting of trust, safety, and integrity components for file transfers.

Treat every URL as untrusted input

Start with strict validation before you send a request.

  • Parse before use. Reject invalid schemes. In most systems that means allow https and handle http only if you explicitly want it.
  • Block local and internal targets. Never let a user-supplied URL decide whether your server talks to private infrastructure.
  • Apply host allowlists when possible. If your product only downloads from customer-approved domains, encode that rule.
  • Set timeouts and byte ceilings. The downloader should stop reading when limits are exceeded.
export function validateRemoteUrl(input) {
  const url = new URL(input);

  if (!["https:", "http:"].includes(url.protocol)) {
    throw new Error("Unsupported URL scheme");
  }

  if (!url.hostname) {
    throw new Error("Missing hostname");
  }

  return url;
}

The rest is defense in depth. If your team wants a broader checklist that goes beyond download code, these important data security tips are worth folding into your review process.

Verify the payload before you trust it

Guidance around downloaders often skips trust and integrity, which is backwards. One practical recommendation is to wrap downloads in try/catch and always check file size because a failed fetch may return a small error page instead of the expected file. It also makes sense to build checks for corrupted or malicious content before the file enters automation, as noted in this download integrity discussion.

That translates into concrete gatekeeping:

CheckWhy it mattersAction
Content-TypeHTML and error pages often slip throughReject or route to resolver
Content-LengthHelps spot truncation and nonsense responsesCompare against actual bytes
File extensionWeak signal, but useful for policyDon't trust it alone
Magic bytesBetter indicator of file typeInspect first bytes when policy matters
Size floorCatches tiny error documentsReject suspiciously small payloads

If your workflow later needs to remove sensitive content from files before storage or sharing, this guide on how to redact documents safely covers the next step in that pipeline.

Log enough to investigate failures

Silent failure is the worst failure mode. Every download attempt should emit structured logs with:

  • A request ID for correlation across services
  • Input URL and normalized URL so duplicates are visible
  • Final URL after redirects
  • Headers you care about, especially content type and length
  • Bytes written
  • Outcome classification, such as resolved, rejected, partial, timeout, auth_failed

“Downloaded successfully” is not an operational event. It's the end of a series of checks you should be able to reconstruct later.

Don't log secrets. If you accept signed URLs or bearer tokens, redact them before they hit logs.

Integrating Downloads with OkraPDF for Processing

The common architecture is download to disk, then upload to a processing API. That works. It's also usually unnecessary.

If the file's destination is another service, the cleaner model is to stream from origin to destination directly. Your app still owns validation, retries, and policy, but it doesn't pay the extra disk I/O and temp-file cleanup cost unless you need a local copy.

Screenshot from https://res.okrapdf.com/assets/articles/downloader-code-example.png

Why proxying beats download then upload

A direct proxy path is better when:

  • The file is transient. Signed links expire, so fewer hops help.
  • The file is large. Streaming avoids local buffering and temp storage churn.
  • The next step is parsing anyway. There's no reason to persist a local artifact first unless your audit requirements say so.
  • You need a stable hosted link after ingestion. Offloading the hosted artifact simplifies downstream use.

This also pairs well with secure transport. If you need a quick refresher for teammates on why TLS matters for document movement, UpTime Web Hosting's SSL guide is a plain-English explainer.

Curl example

For simple workflows, use one service-to-service upload after your own resolver confirms the remote payload is the file you want.

curl -X POST "https://api.okrapdf.com/v1/files" \
  -H "Authorization: Bearer $OKRAPDF_API_KEY" \
  -F "file=@./statement.pdf"

If the output you want is structured data, route the hosted PDF into a converter such as PDF to JSON instead of baking custom extraction logic into your downloader.

JavaScript proxy example

The useful pattern is stream in, stream out:

import axios from "axios";
import FormData from "form-data";

export async function proxyRemoteFileToProcessor({
  sourceUrl,
  sourceHeaders = {},
  processorApiKey,
}) {
  const upstream = await axios({
    method: "GET",
    url: sourceUrl,
    headers: sourceHeaders,
    responseType: "stream",
    maxRedirects: 5,
    validateStatus: (status) => status >= 200 && status < 400,
  });

  const contentType = upstream.headers["content-type"] || "";
  if (contentType.includes("text/html")) {
    upstream.data.destroy();
    throw new Error("Expected file payload, got HTML");
  }

  const form = new FormData();
  form.append("file", upstream.data, {
    filename: "remote-file.pdf",
    contentType: contentType || "application/octet-stream",
  });

  const result = await axios({
    method: "POST",
    url: "https://api.okrapdf.com/v1/files",
    headers: {
      Authorization: `Bearer ${processorApiKey}`,
      ...form.getHeaders(),
    },
    data: form,
    maxBodyLength: Infinity,
    maxContentLength: Infinity,
  });

  return result.data;
}

That architecture gives you one choke point for policy enforcement. You can validate source URLs, reject bad payloads, and still avoid writing the file locally unless you explicitly need retention.

A nice side effect is cleaner cleanup. Streaming pipelines fail fast and leave fewer orphaned files behind than temp-directory-heavy upload flows.

From Scripts to Serverless Deployment Examples

The same downloader logic usually ends up in two places: a local utility and an HTTP endpoint.

Turn it into a CLI

A CLI is the fastest way to make the downloader useful across engineering, support, and ops.

import argparse
from downloader import download_to_file

parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("dest")
args = parser.parse_args()

result = download_to_file(args.url, args.dest)
print(result)

That's enough for repeatable debugging. It also gives you a contract to test before you expose the downloader as a service.

Wrap it in a serverless endpoint

Serverless works well when the function does bounded work, streams efficiently, and enforces strict limits. Accept a URL, validate it, run the downloader or proxy path, and return structured metadata about what happened.

export default {
  async fetch(request) {
    const { url } = await request.json();

    // validate URL
    // normalize URL
    // stream to storage or downstream processor
    // return final URL, type, size, status

    return new Response(JSON.stringify({ ok: true }), {
      headers: { "content-type": "application/json" },
    });
  },
};

The pattern is simple. The details matter. Keep request timeouts tight, reject ambiguous content, and make every outcome observable.

A good URL file downloader isn't a helper function. It's the front door to your document pipeline.


If you need the next step after retrieval, OkraPDF gives you a practical path for document ingestion and PDF processing. You can host a PDF, get a shareable link, or turn PDFs into structured outputs for downstream automation without building every parsing step yourself.