DEV Community

Tristan Kilhwan Chai
Tristan Kilhwan Chai

Posted on

[Homelab AI Project] Ep.8 - Building a High-Concurrency Node.js RSS Collector under 38MB of RAM

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Promise.all Concurrency: 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).
  2. fast-xml-parser: A low-overhead parser that bypasses heavy DOM-tree structures in memory, serializing raw feed buffers to JS objects instantly.
  3. Database-Level Idempotency (ON CONFLICT): To prevent duplicate article ingestion across repetitive scheduled runs, we skip pre-ingestion database SELECT checks. By applying a unique constraint on the link column, we handle deduplication via a single PostgreSQL INSERT ... ON CONFLICT DO NOTHING statement.

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
  }
}
Enter fullscreen mode Exit fullscreen mode

(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));
Enter fullscreen mode Exit fullscreen mode

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)