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.
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 happy path lies
- What a downloader is actually responsible for
- The Core Download Pipeline in Node.js and Python
- Stage one normalize and key the URL
- Node.js streaming downloader
- Python streaming downloader
- Advanced Strategies for Large and Unreliable Files
- Retry the right failures
- Resume instead of restarting
- Reduce latency before the request starts
- Securing Your File Download Workflow
- Treat every URL as untrusted input
- Verify the payload before you trust it
- Log enough to investigate failures
- Integrating Downloads with OkraPDF for Processing
- Why proxying beats download then upload
- Curl example
- JavaScript proxy example
- From Scripts to Serverless Deployment Examples
- Turn it into a CLI
- Wrap it in a serverless endpoint
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:
| Concern | Naive fetch | Production downloader |
|---|---|---|
| Large files | Buffers in memory | Streams to disk or downstream service |
| Temporary failures | Fails once | Retries selectively |
| Redirects | Maybe | Tracked and logged |
| HTML instead of file | Often missed | Detected and rejected or resolved |
| Duplicate requests | Common | Canonicalized by normalized URL |
| Security | Open to abuse | URL validation, limits, allowlists |
| Debugging | Weak | Request 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.

Stage one normalize and key the URL
Normalization is where you decide whether these are the same resource:
https://example.com/file.pdfhttps://example.com/file.pdf?utm_source=xhttps://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
*.partfirst. Rename only after success. - Reject HTML early unless your workflow explicitly expects a page-resolution step.
- Validate post-write size when
Content-Lengthexists. - 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.

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
Rangeand send a full200response. - 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.

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
httpsand handlehttponly 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:
| Check | Why it matters | Action |
|---|---|---|
Content-Type | HTML and error pages often slip through | Reject or route to resolver |
Content-Length | Helps spot truncation and nonsense responses | Compare against actual bytes |
| File extension | Weak signal, but useful for policy | Don't trust it alone |
| Magic bytes | Better indicator of file type | Inspect first bytes when policy matters |
| Size floor | Catches tiny error documents | Reject 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.

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.