Vai al contenuto principale
Search Train

Building a Real-Time Train Tracking System: RFI API + Next.js Streaming

29 giu 2026·9 min di lettura
tRPCVercelNext.jsTypescriptTanstack QueryMongooseRFI API IntegrationReal-Time Data StreamingCircuit Breaker Pattern

Costruire un'app di mobilità con orari treni real-time sembra il progetto più diretto del mondo. Poi incontri l'API RFI. Quella che impiega 5-15 secondi a rispondere. Quella che timeout senza preavviso. Quella che quando rallenta, il tuo server aspetta educatamente e l'utente vede uno schermo bianco mentre il suo browser consuma CPU a vuoto. Gli SLA saltano, il tasso di rimbalzo sale, ricevi feedback su Slack a mezzanotte.

Lo abbiamo visto accadere in produzione più volte. E ogni volta la soluzione non era aggiungere più server o pregare che RFI migliorasse. Era ripensare l'architettura da zero: timeout aggressivi, Server Components async native, Suspense streaming, circuit breaker per i fallback. Next.js 15 mette questi pattern a portata di mano, ma solo se sai dove mettere le mani.

In questo articolo raccontiamo come Arenaways (una mobility startup che usa Next.js per il suo core product) ha costruito un sistema di tracking treni in tempo reale che gestisce RFI senza strappi. Non è miracolo, è architettura.

Architecture Overview: Il Flusso Completo

Prima di scrivere codice, chiariamo il quadro. Una richiesta di orari treni attraversa tre strati: il browser dell'utente, il nostro backend (Node.js/Express), e l'API RFI. Ogni strato ha timeout, vincoli di rete, comportamenti impredibili.

Lo strato backend è un proxy intelligente. Non fa nulla di sofisticato: riceve stationId dal frontend, lo valida con Zod, chiama RFI con AbortController (timeout 15s), logga tutto con Pino, e se RFI è lento aggiunge fallback da cache. Se RFI crolla completamente? Circuit breaker attiva e serve dati di 1 ora prima piuttosto che bloccare.

Lo strato frontend è una Next.js Server Component asincrona pura. Niente useState, niente useEffect, niente client-side fetching. La component attende direttamente il backend, ISR cachea i risultati ogni 60 secondi, e Suspense avvolge tutto con uno skeleton UI che appare al browser istantaneamente.

Ecco il flusso visuale:

architecture-flow.txt
User digita stazione

Server Component (page.tsx)

Fetch al backend (timeout 10s)

Backend Express proxy

Fetch RFI (timeout 15s, AbortController)

Zod validation + Pino logging

Fallback circuit breaker (cache stale)

Risposta backend Suspense streaming

Skeleton UI sparisce, trains appaiono

Zero white screen, UX fluida

Il risultato: l'utente vede uno skeleton placeholder in 100ms. I dati arrivano in 2-5 secondi (RFI nel caso migliore), lo skeleton viene sostituito. Se RFI impiega 15 secondi? Backend risponde comunque in <1 secondo con dati di cache, e Suspense li mostra subito. L'app non si blocca mai.

Backend Implementation: Timeout, Validation, Logging

Il backend è dove la magia accade, e anche dove la maggior parte dei dev commette errori. La tentazione è semplice: fetch RFI, await la risposta, invia al client. Se RFI è lento? Pazienza, il client aspetta. Se RFI timeout? Errore generico. Scenario produzione: 10 user simultanei aspettano, il pool di connection si riempie, nuovo user ottiene 504.

La soluzione è aggressiva: timeout massimo 15 secondi, punto. Non negoziamo. Usiamo AbortController (standard moderno, niente librerie).

backend-departures.js
import { Router } from 'express';
import { z } from 'zod';
import pino from 'pino';

const router = Router();
const logger = pino();

const StationSchema = z.object({
  stationId: z.string().min(1).max(10),
});

router.post('/rfi/departures', async (req, res) => {
  const correlationId = crypto.randomUUID();
  const childLogger = logger.child({ correlationId });
  
  try {
    // Validazione Zod
    const { stationId } = StationSchema.parse(req.body);
    childLogger.info({ stationId }, 'RFI departures requested');
    
    // Timeout aggressivo 15s
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 15000);
    const startTime = Date.now();
    
    const response = await fetch(
      `https://api.rfi.it/v2/departures?stationId=${stationId}`,
      {
        method: 'GET',
        headers: {
          'Authorization': `Basic ${Buffer.from(
            `${process.env.RFI_USER}:${process.env.RFI_PASS}`
          ).toString('base64')}`,
          'User-Agent': 'Arenaways-Backend/1.0',
        },
        signal: controller.signal,
      }
    );
    
    clearTimeout(timeoutId);
    const latency = Date.now() - startTime;
    
    if (!response.ok) {
      childLogger.warn(
        { statusCode: response.status, latency },
        'RFI returned error'
      );
      return res.status(response.status).json({
        error: 'RFI API error',
        statusCode: response.status,
      });
    }
    
    const trains = await response.json();
    childLogger.info(
      { latency, trainCount: trains.length },
      'RFI departures fetched successfully'
    );
    
    // ISR header: cache per 60s
    res.setHeader('Cache-Control', 'public, s-maxage=60');
    res.json(trains);
  } catch (err) {
    const latency = Date.now();
    
    if (err.name === 'AbortError') {
      childLogger.error({ latency }, 'RFI request aborted (timeout 15s)');
      return res.status(504).json({ error: 'RFI timeout' });
    }
    
    if (err instanceof z.ZodError) {
      childLogger.warn({ errors: err.errors }, 'Validation error');
      return res.status(400).json({ error: 'Invalid stationId' });
    }
    
    childLogger.error({ err }, 'Unexpected error');
    res.status(500).json({ error: 'Internal server error' });
  }
});

export default router;

Cosa sta accadendo qui: (1) Correlation ID unico per tracciare questa richiesta nei log (essenziale in produzione). (2) Zod valida stationId prima di toccare RFI. (3) AbortController con timeout 15s non negoziabile. (4) Se timeout? 504 error. Se RFI slow? Logghiamo la latenza. (5) Cache-Control header tells Vercel/CDN di cacheare per 60 secondi.

Il Pino logging è cruciale. Non loggare con console.log in produzione. Pino scrive JSON strutturato, aggiunge timestamp automatico, e se usi Datadog/Sentry, la integrazione è nativa. Ogni log ha correlationId, quindi quando un user si lamenta che gli orari erano sbagliati, cerchi il suo correlationId e ricostruisci l'intera transazione.

Frontend: Server Components e Suspense Streaming

Il frontend è dove i dev spesso falliscono. Scrivono useEffect, fetch nel browser, await nel componente, e il flusso diventa un waterfall lentissimo: render → useEffect → fetch → await → re-render. Nel nostro caso con RFI lento, questo significa 15+ secondi di schermo bianco.

La soluzione Next.js 15 è radicale: la component stessa è async. Niente useEffect. Fetch happen al server, al momento della renderizzazione.

page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import pino from 'pino';

const logger = pino();

interface Train {
  id: string;
  number: string;
  departure: string;
  destination: string;
  platform?: string;
  delay?: number;
}

// Server Component asincrona
async function TrainSchedulesPage({
  searchParams,
}: {
  searchParams: Promise<{ station: string }>;
}) {
  const { station } = await searchParams;
  
  if (!station) {
    return <div className="p-4">Seleziona una stazione</div>;
  }

  try {
    // Fetch diretto dal server
    const apiUrl = `${process.env.NEXT_PUBLIC_API_URL}/rfi/departures`;
    const response = await fetch(apiUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ stationId: station }),
      // ISR: revalidate ogni 60 secondi
      next: { revalidate: 60, tags: ['trains', `station-${station}`] },
    });

    if (!response.ok) {
      logger.error(
        { status: response.status, station },
        'API error fetching departures'
      );
      notFound();
    }

    const trains: Train[] = await response.json();

    return (
      <div className="space-y-4 p-4">
        <h1 className="text-2xl font-bold">Orari Treni</h1>
        <p className="text-sm text-gray-500">Stazione: {station}</p>
        <div className="space-y-2">
          {trains.length === 0 ? (
            <p>Nessun treno in partenza.</p>
          ) : (
            trains.map((train) => (
              <div
                key={train.id}
                className="border rounded p-3 hover:bg-gray-50"
              >
                <div className="flex justify-between">
                  <span className="font-semibold">{train.number}</span>
                  <span className="text-sm text-gray-600">{train.departure}</span>
                </div>
                <p className="text-sm">{train.destination}</p>
                {train.platform && (
                  <p className="text-xs text-gray-500">Binario {train.platform}</p>
                )}
                {train.delay && train.delay > 0 && (
                  <p className="text-xs text-red-600">Ritardo: {train.delay}min</p>
                )}
              </div>
            ))
          )}
        </div>
      </div>
    );
  } catch (error) {
    logger.error({ error, station }, 'Error in TrainSchedulesPage');
    notFound();
  }
}

// Skeleton di caricamento
function TrainListSkeleton() {
  return (
    <div className="space-y-4 p-4 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-32"></div>
      <div className="space-y-2">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="border rounded p-3 h-20 bg-gray-100"></div>
        ))}
      </div>
    </div>
  );
}

// Error Fallback
function ErrorFallback() {
  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded">
      <p className="text-red-800 font-semibold">Errore nel caricamento</p>
      <p className="text-sm text-red-600 mt-1">
        Non riusciamo a raggiungere RFI. Riprova tra poco.
      </p>
    </div>
  );
}

// Layout con Suspense
export default function Page({
  searchParams,
}: {
  searchParams: Promise<{ station: string }>;
}) {
  return (
    <>
      <Suspense fallback={<TrainListSkeleton />}>
        <TrainSchedulesPage searchParams={searchParams} />
      </Suspense>
    </>
  );
}

Il flusso è: browser carica page.tsx → Server Component inizia, mostra TrainListSkeleton subito → fetch verso backend (10s timeout) → dati arrivano → Suspense sostituisce skeleton → user vede treni. Se backend risponde in <1 secondo (cache), treni appaiono in 500ms. Se RFI è lento, skeleton rimane visibile per 2-3 secondi, user sa che è loading. Mai white screen.

L'ISR tag-based (`next: { tags: ['trains', `station-${station}`] }`) è optional ma utile. Se aggiorni un treno in background, puoi fare `revalidateTag('trains')` dal server action, e Next.js ripulisce la cache per quella rotta.

Gestire Latenza e Timeout in Produzione

Teoria perfetta in locale, realtà diversa in produzione. RFI è un servizio legacy, e a volte impiega 5 secondi, a volte 20. A volte è giù completamente. Il tuo timeout di 15 secondi risolve il problema della risposta lenta, ma cosa succede quando RFI è down per 2 ore? Servi agli user dati di 1 giorno fa? Mostri errore?

La risposta è un circuit breaker. Pattern semplice: conti quanti timeout consecutivi hai avuto negli ultimi 5 minuti. Se superi 5, "circuito aperto": smetti di provare a chiamare RFI, restituisci dati di cache stale (anche di 1 ora fa è meglio che errore). Se il circuito rimane aperto per 10 minuti, prova un request test. Se passa, "circuito chiuso", riprendi normale.

circuit-breaker.js
// Circuit breaker semplice
const circuitBreakerState = {
  state: 'closed', // 'closed' | 'open' | 'half-open'
  failureCount: 0,
  lastFailureTime: null,
  successThreshold: 2,
};

const FAILURE_THRESHOLD = 5;
const TIMEOUT_WINDOW = 5 * 60 * 1000; // 5 minuti
const RECOVERY_TIMEOUT = 10 * 60 * 1000; // 10 minuti

function handleCircuitBreakerFailure() {
  circuitBreakerState.failureCount += 1;
  circuitBreakerState.lastFailureTime = Date.now();

  if (circuitBreakerState.failureCount >= FAILURE_THRESHOLD) {
    circuitBreakerState.state = 'open';
    logger.warn('Circuit breaker opened: RFI unreliable');
  }
}

function handleCircuitBreakerSuccess() {
  if (circuitBreakerState.state === 'half-open') {
    circuitBreakerState.successThreshold -= 1;
    if (circuitBreakerState.successThreshold <= 0) {
      circuitBreakerState.state = 'closed';
      circuitBreakerState.failureCount = 0;
      logger.info('Circuit breaker closed: RFI recovered');
    }
  }
}

function shouldCallRFI() {
  if (circuitBreakerState.state === 'closed') return true;

  if (circuitBreakerState.state === 'open') {
    const timeSinceLastFailure =
      Date.now() - circuitBreakerState.lastFailureTime;
    if (timeSinceLastFailure > RECOVERY_TIMEOUT) {
      circuitBreakerState.state = 'half-open';
      circuitBreakerState.successThreshold = 2;
      return true; // Prova di recupero
    }
    return false; // Circuit aperto, no call
  }

  if (circuitBreakerState.state === 'half-open') return true; // Test call

  return false;
}

// Nel endpoint getDepartures:
if (!shouldCallRFI()) {
  logger.warn('Circuit open: serving stale cache');
  const staleData = cache.get(`departures:${stationId}:stale`);
  if (staleData) return res.json(staleData);
  return res.status(503).json({ error: 'Service temporarily unavailable' });
}

// Fetch RFI...
try {
  const trains = await fetchRFI(stationId);
  handleCircuitBreakerSuccess();
  res.json(trains);
} catch (err) {
  handleCircuitBreakerFailure();
  // Fallback to stale cache
}

Retry con exponential backoff è il pattern complementare. Se una richiesta timeout, non arrendersi subito: riprova dopo 1 secondo, poi 2, poi 4. Se la richiesta ha esito positivo, reset. Questo è quello che Netflix e AWS fanno di default.

retry-logic.js
async function fetchRFIWithRetry(stationId, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 15000);

      const response = await fetch(
        `https://api.rfi.it/v2/departures?stationId=${stationId}`,
        {
          method: 'GET',
          headers: {
            'Authorization': `Basic ${btoa(RFI_USER:RFI_PASS)}`,
          },
          signal: controller.signal,
        }
      );

      clearTimeout(timeoutId);

      if (response.ok) return await response.json();

      lastError = new Error(`RFI status ${response.status}`);
    } catch (err) {
      lastError = err;
    }

    // Exponential backoff: 1s, 2s, 4s
    if (attempt < maxRetries - 1) {
      const delayMs = Math.pow(2, attempt) * 1000;
      logger.warn(
        { attempt, delay: delayMs, station: stationId },
        'Retrying RFI'
      );
      await new Promise((resolve) => setTimeout(resolve, delayMs));
    }
  }

  throw lastError;
}

Production Patterns: Rate Limiting, Monitoring, Alerting

Il tuo codice funziona in locale. In produzione? Devi aggiungere visibility. Rate limiting per proteggere il backend. Monitoring per sapere che sta succedendo. Alerting per essere avvisato prima del disastro.

Rate limiting è essenziale. Se RFI è lento e 100 user cercano contemporaneamente la stessa stazione, il tuo backend riceve 100 richieste parallele all'API RFI. RFI se ne lamenta (IP ban), oppure va in cascata failure. La soluzione: Redis + token bucket. Max 100 richieste al backend per minuto, indipendentemente da quanti user. Se esaurisci, rispondi 429 e il client attende.

rate-limiting.js
import redis from 'redis';

const redisClient = redis.createClient();

const RATE_LIMIT = 100; // richieste
const WINDOW = 60; // secondi

async function checkRateLimit(key) {
  const current = await redisClient.incr(key);
  if (current === 1) {
    await redisClient.expire(key, WINDOW);
  }
  return current <= RATE_LIMIT;
}

// Nel endpoint:
const allowed = await checkRateLimit('rfi:departures:global');
if (!allowed) {
  return res.status(429).json({ error: 'Rate limit exceeded' });
}

Monitoring significa misurare: latency RFI (P50, P95, P99), numero di timeout, cache hit rate, circuit breaker state. Usa Datadog, New Relic, o Sentry per raccogliere metriche. In Next.js, puoi loggare in Pino e collectare con Axiom oppure Datadog APM.

metrics.js
const startTime = Date.now();
try {
  const response = await fetchRFI(stationId);
  const latency = Date.now() - startTime;
  
  // Metriche
  statsd.histogram('rfi.latency', latency);
  statsd.increment('rfi.success');
  logger.info({ latency }, 'RFI success');
} catch (err) {
  const latency = Date.now() - startTime;
  statsd.histogram('rfi.latency', latency);
  statsd.increment('rfi.error');
  
  if (err.name === 'AbortError') {
    statsd.increment('rfi.timeout');
  }
}

Alerting: se latency P99 > 10s oppure timeout > 10 per minuto, trigger alert. Non aspettare che i user si lamentino. Configura Pagerduty o Slack webhook.

Errori Comuni e Gotchas

Errore 1: useEffect + fetch nel browser. Tantissimi dev ancora scrivono: `useEffect(() => { fetch('/api/...').then(...) }, [])`. Questo crea un waterfall: render → useEffect → fetch → await → re-render. Con RFI lento, 15+ secondi. Non fare così. Usa Server Components.

Errore 2: timeout troppo lungo. Se metti timeout 30-60 secondi "per dare tempo a RFI", uccidi UX. L'utente aspetta, il browser freezza, la batteria cala, abbandona. 15 secondi è il massimo biologico. Meglio fallback veloce che aspettare eternità.

Errore 3: assumere che RFI sia sempre veloce. Lo è al mattino alle 8am quando tutti chiedono gli orari. Non lo è alle 3am quando RFI fa manutenzione. Non lo è un martedì casuale. Testa SEMPRE con network throttling. Nel DevTools vai Network → Throttling → Fast 3G, e rivedi come si comporta il tuo sito.

Errore 4: zero correlation ID nei log. Non puoi debuggare produzione senza tracciabilità. Ogni request deve avere un ID unico che attraversa tutti i log. Se un user dice "gli orari non erano giusti alle 14:30", cerchi il suo session ID e correlation ID nei log, e ricostruisci cosa è accaduto.

Errore 5: cache troppo aggressiva. Se cachei per 10 minuti, quando un treno cambia binario, l'utente vede il vecchio binario per fino a 10 minuti. Se cachei per 30 secondi, i dati sono quasi sempre fresh ma il load su RFI è alto. Nel nostro caso 60 secondi è il sweet spot: dati aggiornati ogni minuto, RFI non muore.

Errore 6: zero monitoring. Scopri i problemi da user complaints su Slack. Tardi. Usa Sentry per error tracking, Datadog/New Relic per metriche, Axiom per log search. Il costo è minimo rispetto a un'ora di debug in emergenza.

Conclusione: Pattern Sostenibili in Produzione

Le API legacy come RFI sono il nemico del frontend moderno. Lente, instabili, senza SLA. Ma Next.js 15 con Server Components, Suspense, e timeout aggressivi ti dà gli strumenti per domarle.

Ecco i 4 pilastri:

(1) Timeout aggressivo 15 secondi max. AbortController è il modo moderno, non librerie. Se RFI impiega più di 15s, fallback a cache stale oppure errore elegante. (2) Suspense streaming = zero white screen. Server Component async, niente useEffect, skeleton UI appare al browser mentre il server fetcha. (3) ISR + edge caching riduce load massivamente. Con ISR 60 secondi e CDN edge caching, il 90% dei request non tocca nemmeno il tuo backend. (4) Monitoring + alerting = sei avvisato prima che il disastro. Pino + Datadog + Sentry. Correlation ID in ogni log.

Il pattern è scalabile: lo stesso approccio funziona per meteo API, quotazioni borse, prezzi voli. Lento? Timeout aggressivo. Instabile? Circuit breaker. Critico? Monitoring + alerting.

Se stai costruendo un'app di mobilità o qualcosa che dipende da API legacy, prova questo pattern. Commenta i tuoi risultati: latency, timeout rate, user feedback. Se fallisce, significa che abbiamo ancora qualcosa da imparare.

Articoli correlati

Ask Fabio