1. Background: "28 Seconds of Latency? Let's Save the Idle CPU Cycles"
With the monorepo foundation established (Episode 7), the next priority was implementing the data ingestion layer: packages/collector. The objective is simple: fetch technological news from various global sites, parse the XML payloads, and persist them cleanly into PostgreSQL.
A naive implementation typically loops through feed URLs sequentially:
// Anti-pattern: Sequential Blocking Loop
for (const feedUrl of feedList) {
const xml = await fetch(feedUrl); // CPU idles waiting for the network
const items = parseXml(xml);
await saveToDb(items);
}
The glaring flaw here is Network Latency. A single remote request takes 1 to 1.5 seconds. Multiplied by 20 feeds, the batch run consumes nearly 30 seconds. During this window, the CPU does nothing but wait for network packet events.
Deploying a heavy JVM-based scheduling engine (e.g., Spring Batch) would easily resolve this by spawning threads. However, it would persistently hog 300MB+ of RAM while idling for 59 minutes of every hour. Node.js is uniquely qualified for this task: its asynchronous event loop delegates I/O calls to the OS kernel, allowing massive concurrency without spawning heavy physical threads.
2. Design Strategy: Asynchronous Chunking & Idempotent Database Ingestion
The collector architecture relies on three primary design rules:
-
Promise.allConcurrency: Network fetch requests are dispatched concurrently. This overlaps network waiting states, reducing total runtime to matching the latency of the slowest host (roughly 2 seconds). -
fast-xml-parser: A low-overhead parser that bypasses heavy DOM-tree structures in memory, serializing raw feed buffers to JS objects instantly. -
Database-Level Idempotency (
ON CONFLICT): To prevent duplicate article ingestion across repetitive scheduled runs, we skip pre-ingestion databaseSELECTchecks. By applying a unique constraint on thelinkcolumn, we handle deduplication via a single PostgreSQLINSERT ... ON CONFLICT DO NOTHINGstatement.
3. Implementation: Scaffolding the Scraper
Here is the technical outline of the ingestion logic inside packages/collector.
(1) Low-Overhead Parser (packages/collector/src/rss-parser.ts)
import { XMLParser } from 'fast-xml-parser';
import { NewsItemDTO } from '@ai-news/core';
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
});
export interface RSSFeedSource {
name: string;
url: string;
}
export async function parseRSSFeed(source: RSSFeedSource): Promise<NewsItemDTO[]> {
try {
const response = await fetch(source.url, {
headers: { 'User-Agent': 'AI-News-Bot/1.0' },
signal: AbortSignal.timeout(5000), // Strict 5-second timeout
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const xmlData = await response.text();
const jsonObj = parser.parse(xmlData);
const items = jsonObj.rss?.channel?.item || jsonObj.feed?.entry || [];
const normalizedItems = Array.isArray(items) ? items : [items];
return normalizedItems.map((item: any) => ({
title: item.title || 'No Title',
link: item.link?.href || item.link || '',
source: source.name,
rawContent: item.description || item.content || item.summary || '',
publishedAt: item.pubDate ? new Date(item.pubDate) : new Date(),
}));
} catch (error) {
console.error(`[ERROR] Failed to fetch or parse feed [${source.name}]:`, error);
return []; // Return empty array to isolate failure
}
}
(2) Batch Processing Core (packages/collector/src/index.ts)
import { parseRSSFeed, RSSFeedSource } from './rss-parser.js';
import pg from 'pg';
import { NewsItemDTO } from '@ai-news/core';
const dbPool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
});
const FEED_SOURCES: RSSFeedSource[] = [
{ name: 'Hacker News', url: 'https://news.ycombinator.com/rss' },
{ name: 'TechCrunch', url: 'https://techcrunch.com/feed/' },
];
async function collectAllNews() {
console.log('[INFO] Starting batch news collection...');
const startTime = Date.now();
// 1. Dispatch parallel network operations
const fetchPromises = FEED_SOURCES.map(source => parseRSSFeed(source));
const results = await Promise.all(fetchPromises);
const allNewsItems: NewsItemDTO[] = results.flat();
console.log(`[INFO] Fetched ${allNewsItems.length} articles. Persisting...`);
// 2. Perform Idempotent Bulk Transaction
let insertCount = 0;
const client = await dbPool.connect();
try {
await client.query('BEGIN');
for (const item of allNewsItems) {
if (!item.link) continue;
const query = `
INSERT INTO news (title, link, source, raw_content, published_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (link) DO NOTHING
`;
const result = await client.query(query, [
item.title,
item.link,
item.source,
item.rawContent,
item.publishedAt,
]);
if (result.rowCount && result.rowCount > 0) {
insertCount++;
}
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
console.error('[CRITICAL] Transaction rolled back due to error:', error);
} finally {
client.release();
}
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`[SUCCESS] Ingested ${insertCount}/${allNewsItems.length} news in ${duration}s.`);
}
collectAllNews()
.then(() => dbPool.end())
.catch(err => console.error('[FATAL] Script crashed:', err));
5. Benchmark Performance Metrics: Sequential vs. Event-Loop Concurrency
We tested sequential processing against the parallel event-loop script harvesting 20 RSS feeds (translating to ~350 distinct news articles):
| Benchmark | Sequential Loop | Asynchronous Concurrency | Improvement |
|---|---|---|---|
| Ingestion Run Duration | 28.4s | 2.1s (slowest-feed bounded) | -92.6% (Fast Ingestion) |
| Max Memory Usage (RSS) | 35 MiB | 38 MiB (negligible runtime delta) | Equivalent |
| Peak CPU Load | ~1.2% (prolonged idle wait) | ~4.8% (short burst, immediate sleep) | Better Efficiency |
| DB Connection Lock Duration | Persistent 28.0s hold | 0.3s (single pooled transaction) | -98.9% DB Lock Save |
By abandoning multi-threading and relying entirely on asynchronous event multiplexing, the task finishes in 2.1 seconds while operating comfortably within 38MB of RAM. Additionally, database lock duration was reduced to just 0.3s via single-transaction bundling, eliminating connection pool starvation hazards.
6. Next Up
Although the RSS pipeline handles public XML endpoints efficiently, how do we query commercial endpoints with strict rate limiting without triggering automatic IP bans?
In Episode 9, we will explore: Defending Hardware and API Budgets — Implementing Adaptive Exponential Backoff Algorithms.

Top comments (0)