Skip to main content
backend26 octobre 202515 min de lecture

Données en temps réel : WebSockets, SSE, et quand vous en avez vraiment besoin

Comprendre les patterns de données en temps réel — des WebSockets aux Server-Sent Events, Firebase Realtime Database, et savoir quand le polling suffit.

websocketsrealtimesse
Données en temps réel : WebSockets, SSE, et quand vous en avez vraiment besoin

La plupart des applications qui prétendent avoir besoin de données en temps réel n'en ont pas réellement besoin. Elles ont besoin de données qui semblent actuelles — mises à jour en quelques secondes, pas en quelques millisecondes. La distinction compte car les architectures temps réel véritables introduisent de la complexité dans la gestion des connexions, la synchronisation d'état, le scaling et la gestion d'erreurs qui sont entièrement inutiles si vos utilisateurs seraient parfaitement satisfaits d'un délai de cinq secondes.

J'ai construit des fonctionnalités véritablement temps réel — suivi de commandes en direct pour une plateforme de restauration, interfaces d'édition collaborative, tableaux de bord en direct — et j'ai aussi construit des fonctionnalités où j'avais initialement sur-ingénieré avec des WebSockets pour finalement les remplacer par du polling qui rafraîchit toutes les dix secondes. Personne n'a remarqué le changement. Commencer avec l'approche la plus simple et n'ajouter de la complexité que quand les utilisateurs rencontrent réellement un problème n'est pas seulement pragmatique — c'est le choix d'ingénierie responsable.

Quand vous avez vraiment besoin du temps réel

Le vrai temps réel est justifié quand la valeur de l'information se dégrade rapidement avec la latence.

Chat et messagerie. Les utilisateurs s'attendent à ce que les messages apparaissent en moins d'une seconde. Un délai de cinq secondes rend une conversation bancale. Le temps réel n'est pas optionnel ici.

Edition collaborative. L'édition simultanée style Google Docs nécessite une synchronisation en dessous de la seconde pour éviter les éditions conflictuelles. La complexité ici va au-delà du simple transport — vous avez besoin de transformation opérationnelle ou de CRDT pour la résolution de conflits.

Tableaux de bord en direct avec impact opérationnel. Un tableau de bord de monitoring où un ingénieur surveille les anomalies pendant un déploiement a besoin de mises à jour en temps réel. Un tableau de bord analytics consulté une fois par jour, non.

Gaming et expériences interactives. Jeux multijoueurs, enchères en direct, sondages en temps réel — tout ce où plusieurs utilisateurs interagissent avec un état partagé simultanément.

Trading financier. Flux de prix, mises à jour du carnet d'ordres, changements de position. Les millisecondes comptent ici, et l'architecture le reflète.

Quand vous n'avez pas besoin du temps réel

Fils d'actualité des réseaux sociaux. Twitter et Instagram semblent temps réel, mais ils utilisent une combinaison de polling au focus et de notifications push. Le fil ne se met pas à jour pendant que vous le regardez — vous tirez pour rafraîchir.

Stock e-commerce. "Plus que 3 !" n'a pas besoin de se mettre à jour en temps réel. Vérifier au chargement de la page et au checkout suffit.

Compteurs de notifications. Le badge rouge montrant "5 nouvelles notifications" peut être pollé toutes les 30 secondes. Les utilisateurs ne remarquent pas si une notification apparaît 30 secondes après sa création.

Mises à jour de contenu. Articles de blog, fiches produits, profils utilisateurs — toute donnée qui change rarement. Pollez au chargement de la page ou utilisez les headers de cache HTTP avec revalidation.

Polling : le choix par défaut sous-estimé

Le polling — faire des requêtes HTTP périodiques pour vérifier les nouvelles données — est l'approche la plus simple et fonctionne pour plus de cas d'usage que la plupart des développeurs ne le supposent.

Polling simple

function usePolledData<T>(url: string, intervalMs: number = 5000) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let active = true;

    const fetchData = async () => {
      try {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const result = await response.json();
        if (active) setData(result);
      } catch (e) {
        if (active) setError(e as Error);
      }
    };

    fetchData(); // Initial fetch
    const interval = setInterval(fetchData, intervalMs);

    return () => {
      active = false;
      clearInterval(interval);
    };
  }, [url, intervalMs]);

  return { data, error };
}

Polling intelligent avec requêtes conditionnelles

Les requêtes conditionnelles HTTP (If-None-Match avec ETags ou If-Modified-Since) permettent de poller efficacement. Le serveur renvoie 304 Not Modified sans corps si les données n'ont pas changé, réduisant la bande passante à quasi zéro pour les ressources inchangées.

async function pollWithETag(url: string): Promise<{ data: any; changed: boolean }> {
  const cachedETag = etagCache.get(url);

  const headers: HeadersInit = {};
  if (cachedETag) {
    headers['If-None-Match'] = cachedETag;
  }

  const response = await fetch(url, { headers });

  if (response.status === 304) {
    return { data: dataCache.get(url), changed: false };
  }

  const etag = response.headers.get('ETag');
  if (etag) etagCache.set(url, etag);

  const data = await response.json();
  dataCache.set(url, data);

  return { data, changed: true };
}

Quand le polling atteint ses limites

Le polling cesse d'être viable quand :

  • Vous avez besoin d'une latence en dessous de la seconde. Poller toutes les 500ms est essentiellement une attaque DoS contre votre propre API.
  • Les données changent rarement mais doivent être livrées instantanément. Poller toutes les 5 secondes pour un événement qui se produit une fois par heure gaspille 719 requêtes par heure.
  • Vous avez des milliers de clients. Chaque client en polling est une connexion indépendante. A grande échelle, le surcoût HTTP d'établissement de connexions, de parsing de headers et d'authentification s'accumule.

Server-Sent Events : le temps réel plus simple

Les Server-Sent Events (SSE) sont la technologie temps réel la plus sous-utilisée en développement web. SSE fournit un flux unidirectionnel du serveur vers le client sur une connexion HTTP standard. Le navigateur gère la reconnexion automatiquement, et le protocole est trivialement simple.

Implémentation serveur

// Express.js SSE endpoint
app.get('/events/orders/:restaurantId', (req, res) => {
  const { restaurantId } = req.params;

  // SSE headers
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no', // Disable nginx buffering
  });

  // Send initial data
  const initialOrders = getActiveOrders(restaurantId);
  res.write(`data: ${JSON.stringify(initialOrders)}\n\n`);

  // Subscribe to order changes
  const unsubscribe = orderEmitter.on(`orders:${restaurantId}`, (order) => {
    res.write(`event: orderUpdate\n`);
    res.write(`data: ${JSON.stringify(order)}\n`);
    res.write(`id: ${order.id}-${order.updatedAt}\n\n`);
  });

  // Heartbeat to detect dead connections
  const heartbeat = setInterval(() => {
    res.write(`: heartbeat\n\n`);
  }, 30000);

  // Cleanup on disconnect
  req.on('close', () => {
    clearInterval(heartbeat);
    unsubscribe();
  });
});

Implémentation client

function useSSE(url: string) {
  const [data, setData] = useState(null);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    const eventSource = new EventSource(url);

    eventSource.onopen = () => setConnected(true);

    eventSource.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };

    eventSource.addEventListener('orderUpdate', (event) => {
      const order = JSON.parse(event.data);
      setData(prev => updateOrderInList(prev, order));
    });

    eventSource.onerror = () => {
      setConnected(false);
      // EventSource automatically reconnects
    };

    return () => eventSource.close();
  }, [url]);

  return { data, connected };
}

Pourquoi SSE plutôt que WebSockets

Reconnexion automatique. L'implémentation EventSource du navigateur gère la reconnexion avec backoff exponentiel automatiquement. Avec les WebSockets, vous implémentez cela vous-même.

Fonctionne à travers les proxies et load balancers. SSE utilise du HTTP standard, donc ça fonctionne à travers nginx, CloudFlare et la plupart des reverse proxies sans configuration spéciale. Les WebSockets nécessitent un support proxy explicite.

Authentification plus simple. Les connexions SSE portent les cookies et peuvent utiliser les headers d'authentification HTTP standard. L'authentification WebSocket nécessite d'envoyer les identifiants après l'établissement de la connexion, ce qui est un protocole supplémentaire à implémenter.

Types d'événements et IDs intégrés. Le protocole SSE supporte les événements nommés et les identifiants de messages, ce qui permet au client de reprendre là où il s'est arrêté après une reconnexion.

Limitations de SSE

  • Unidirectionnel seulement. Du serveur vers le client. Si vous avez besoin de communication bidirectionnelle, SSE ne peut pas le faire. Vous pouvez combiner SSE avec des requêtes HTTP POST classiques pour les messages client vers serveur, ce qui fonctionne bien pour de nombreux cas d'usage.
  • Limites de connexions navigateur. Les navigateurs limitent le nombre de connexions HTTP simultanées vers un même domaine (typiquement six). Chaque connexion SSE compte dans cette limite. Le multiplexage HTTP/2 atténue ce problème, mais il est bon d'en être conscient.
  • Pas de données binaires. SSE est en texte seul. Si vous devez streamer des données binaires (audio, vidéo, fichiers), utilisez les WebSockets ou un mécanisme séparé.

WebSockets : communication full duplex

Les WebSockets fournissent une communication full-duplex — le client et le serveur peuvent envoyer des messages à tout moment sans le surcoût des cycles requête/réponse HTTP. C'est nécessaire quand vous avez besoin d'une communication bidirectionnelle à faible latence.

Implémentation serveur avec ws

import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';

const server = createServer();
const wss = new WebSocketServer({ server });

// Connection management
const rooms = new Map<string, Set<WebSocket>>();

wss.on('connection', (ws, req) => {
  // Authenticate the connection
  const token = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('token');
  const user = verifyToken(token);

  if (!user) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  // Attach user data
  (ws as any).userId = user.id;

  ws.on('message', (raw) => {
    try {
      const message = JSON.parse(raw.toString());
      handleMessage(ws, user, message);
    } catch (e) {
      ws.send(JSON.stringify({ error: 'Invalid message format' }));
    }
  });

  ws.on('close', () => {
    // Remove from all rooms
    rooms.forEach((clients, roomId) => {
      clients.delete(ws);
      if (clients.size === 0) rooms.delete(roomId);
    });
  });

  // Heartbeat
  (ws as any).isAlive = true;
  ws.on('pong', () => { (ws as any).isAlive = true; });
});

// Dead connection detection
const heartbeatInterval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!(ws as any).isAlive) {
      ws.terminate();
      return;
    }
    (ws as any).isAlive = false;
    ws.ping();
  });
}, 30000);

function handleMessage(ws: WebSocket, user: any, message: any) {
  switch (message.type) {
    case 'join_room':
      joinRoom(ws, message.roomId);
      break;
    case 'leave_room':
      leaveRoom(ws, message.roomId);
      break;
    case 'broadcast':
      broadcastToRoom(message.roomId, {
        type: 'message',
        userId: user.id,
        content: message.content,
        timestamp: Date.now(),
      }, ws);
      break;
  }
}

function joinRoom(ws: WebSocket, roomId: string) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);
}

function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket) {
  const clients = rooms.get(roomId);
  if (!clients) return;

  const payload = JSON.stringify(data);
  clients.forEach((client) => {
    if (client !== exclude && client.readyState === WebSocket.OPEN) {
      client.send(payload);
    }
  });
}

server.listen(8080);

Implémentation client avec reconnexion

class WebSocketClient {
  private ws: WebSocket | null = null;
  private url: string;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;
  private handlers = new Map<string, Set<(data: any) => void>>();

  constructor(url: string) {
    this.url = url;
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      this.emit('connected', null);
    };

    this.ws.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data);
        this.emit(message.type, message);
      } catch (e) {
        console.error('Failed to parse WebSocket message:', e);
      }
    };

    this.ws.onclose = (event) => {
      this.emit('disconnected', { code: event.code, reason: event.reason });

      if (event.code !== 4001 && this.reconnectAttempts < this.maxReconnectAttempts) {
        this.scheduleReconnect();
      }
    };

    this.ws.onerror = () => {
      // Error is followed by close event, handle reconnection there
    };
  }

  private scheduleReconnect() {
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
    this.reconnectAttempts++;

    setTimeout(() => {
      this.emit('reconnecting', { attempt: this.reconnectAttempts });
      this.connect();
    }, delay);
  }

  send(type: string, data: any) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type, ...data }));
    }
  }

  on(event: string, handler: (data: any) => void) {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler);
    return () => this.handlers.get(event)?.delete(handler);
  }

  private emit(event: string, data: any) {
    this.handlers.get(event)?.forEach((handler) => handler(data));
  }

  disconnect() {
    this.maxReconnectAttempts = 0; // Prevent reconnection
    this.ws?.close();
  }
}

Stratégie de reconnexion

La logique de reconnexion mérite attention car elle affecte directement l'expérience utilisateur et la charge serveur.

Le backoff exponentiel empêche les problèmes de thundering herd. Si votre serveur redémarre et que 10 000 clients se reconnectent simultanément, le serveur plante à nouveau. En ajoutant du jitter et des délais exponentiels, les connexions s'étalent dans le temps :

function getReconnectDelay(attempt: number): number {
  const baseDelay = 1000;
  const maxDelay = 30000;
  const exponentialDelay = baseDelay * Math.pow(2, attempt);
  const jitter = Math.random() * 1000; // Random jitter up to 1 second
  return Math.min(exponentialDelay + jitter, maxDelay);
}

L'UI d'état de connexion compte. Les utilisateurs doivent savoir quand ils sont déconnectés et quand l'application essaie de se reconnecter. Un petit bandeau disant "Reconnexion en cours..." est mieux qu'afficher silencieusement des données périmées.

Socket.IO : confort vs contrôle

Socket.IO est la bibliothèque WebSocket la plus populaire, et elle offre un confort significatif : reconnexion automatique, gestion des rooms, accusés de réception et fallback vers le polling quand les WebSockets ne sont pas disponibles. Elle ajoute aussi un surcoût significatif et introduit une couche d'abstraction qui peut masquer les problèmes.

Quand utiliser Socket.IO

  • Prototypage rapide. L'API de Socket.IO est plus simple que les WebSockets bruts, et les fonctionnalités intégrées font gagner du temps de développement.
  • Quand vous avez besoin d'un fallback polling. Certains réseaux d'entreprise bloquent les connexions WebSocket. Socket.IO se rabat automatiquement sur le long polling HTTP.
  • Gestion des rooms et namespaces. Si votre fonctionnalité temps réel s'organise naturellement en rooms (salons de chat, lobbies de jeu, documents collaboratifs), les abstractions de rooms de Socket.IO économisent du boilerplate.

Quand éviter Socket.IO

  • Applications haute performance. Le protocole de Socket.IO ajoute du surcoût à chaque message (framing, encodage, métadonnées). Pour les applications à haut débit, les WebSockets bruts sont mesurément plus rapides.
  • Quand vous avez besoin d'interopérabilité. Socket.IO n'est pas du WebSocket standard — un client WebSocket classique ne peut pas se connecter à un serveur Socket.IO. Si vos clients incluent des environnements non-JavaScript (applications mobiles, appareils IoT), Socket.IO nécessite une bibliothèque client pour chaque plateforme.
  • Quand vous voulez comprendre ce qui se passe. Socket.IO abstrait la gestion des connexions, ce qui est pratique jusqu'à ce que quelque chose tourne mal. Déboguer un problème de connexion Socket.IO signifie souvent comprendre la couche d'abstraction en plus du problème réel.

Firebase Realtime Database et Firestore

Firebase fournit des capacités temps réel sans gérer d'infrastructure WebSocket. C'est sa proposition de valeur principale — vous échangez le contrôle contre le confort.

Firebase Realtime Database

import { getDatabase, ref, onValue, set } from 'firebase/database';

const db = getDatabase();

// Listen for real-time updates
const ordersRef = ref(db, `restaurants/${restaurantId}/orders`);
onValue(ordersRef, (snapshot) => {
  const orders = snapshot.val();
  updateUI(orders);
});

// Write data (triggers listeners on all connected clients)
await set(ref(db, `restaurants/${restaurantId}/orders/${orderId}`), {
  status: 'preparing',
  updatedAt: Date.now(),
});

La Realtime Database est un arbre JSON. Chaque écriture sur n'importe quel noeud se propage immédiatement à chaque client écoutant ce noeud ou tout noeud parent. C'est puissant mais dangereux — un listener sur un noeud de haut niveau reçoit les mises à jour pour chaque changement dans tout le sous-arbre.

Listeners temps réel Firestore

import { getFirestore, collection, onSnapshot, query, where } from 'firebase/firestore';

const db = getFirestore();

// Listen for active orders
const q = query(
  collection(db, 'orders'),
  where('restaurantId', '==', restaurantId),
  where('status', 'in', ['pending', 'preparing', 'ready'])
);

const unsubscribe = onSnapshot(q, (snapshot) => {
  snapshot.docChanges().forEach((change) => {
    if (change.type === 'added') addOrder(change.doc.data());
    if (change.type === 'modified') updateOrder(change.doc.data());
    if (change.type === 'removed') removeOrder(change.doc.id);
  });
});

Les listeners temps réel de Firestore sont plus granulaires que la Realtime Database — vous pouvez écouter des requêtes spécifiques, pas seulement des chemins. La méthode docChanges() vous dit exactement ce qui a changé, ce qui rend possibles des mises à jour UI efficaces.

Supabase Realtime

Supabase offre des capacités temps réel par-dessus PostgreSQL, ce qui vous donne la puissance de requête de SQL avec des mises à jour en temps réel. Il utilise les fonctionnalités de réplication de PostgreSQL (réplication logique et le WAL) pour détecter les changements et les diffuser via des canaux WebSocket.

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(url, key);

// Listen for changes to the orders table
const channel = supabase
  .channel('orders')
  .on(
    'postgres_changes',
    {
      event: '*',
      schema: 'public',
      table: 'orders',
      filter: `restaurant_id=eq.${restaurantId}`,
    },
    (payload) => {
      console.log('Change type:', payload.eventType);
      console.log('New data:', payload.new);
      console.log('Old data:', payload.old);
    }
  )
  .subscribe();

L'avantage par rapport à Firebase est clair : vos données vivent dans PostgreSQL avec des capacités de requête SQL complètes, des transactions ACID et un outillage standard. La couche temps réel est un ajout, pas un remplacement de votre moteur de requête. Le compromis est que Supabase Realtime est moins éprouvé à grande échelle que Firebase, et la performance temps réel dépend du débit de réplication de PostgreSQL.

Scaler les connexions WebSocket

Un seul serveur peut gérer des dizaines de milliers de connexions WebSocket, mais scaler au-delà d'un seul serveur introduit un problème de coordination : un message publié sur le Serveur A doit atteindre les clients connectés au Serveur B.

Le pattern Pub/Sub

Redis pub/sub est la solution standard :

import { createClient } from 'redis';

const publisher = createClient();
const subscriber = createClient();

await publisher.connect();
await subscriber.connect();

// When a message comes in on any server, publish to Redis
function publishToRoom(roomId: string, message: any) {
  publisher.publish(`room:${roomId}`, JSON.stringify(message));
}

// Each server subscribes and forwards to its local connections
await subscriber.subscribe(`room:${roomId}`, (message) => {
  const data = JSON.parse(message);
  broadcastToLocalClients(roomId, data);
});

Ce pattern vous permet d'exécuter plusieurs serveurs WebSocket derrière un load balancer. Chaque serveur gère ses propres connexions, et Redis coordonne la communication inter-serveurs.

Sessions persistantes

Les connexions WebSocket sont stateful — une fois établies, elles doivent rester sur le même serveur. Les load balancers ont besoin de sessions persistantes (aussi appelées affinité de session) pour router la connexion WebSocket d'un client vers le même serveur qui a géré l'upgrade HTTP initial.

Avec nginx :

upstream websocket_servers {
    ip_hash;  # Sticky sessions based on client IP
    server ws1.example.com:8080;
    server ws2.example.com:8080;
}

server {
    location /ws {
        proxy_pass http://websocket_servers;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 86400;  # 24 hours
    }
}

Temps réel vs quasi-temps réel

Il y a un spectre entre "vérifier les mises à jour quand l'utilisateur rafraîchit la page" et "livrer chaque changement en quelques millisecondes." La plupart des fonctionnalités se situent quelque part entre les deux.

Approche Latence Complexité Idéal pour
Rafraîchissement de page Initié par l'utilisateur Aucune Contenu statique, changements rares
Polling (30s) Jusqu'à 30 secondes Faible Compteurs de notifications, mises à jour de tableau de bord
Polling (5s) Jusqu'à 5 secondes Faible Fils d'activité, statut de commande
Long polling Sous la seconde Modérée Systèmes anciens, environnements hostiles aux proxies
SSE Sous la seconde Modérée Flux en direct, notifications, streams unidirectionnels
WebSockets Sous la seconde Elevée Chat, collaboration, temps réel bidirectionnel
WebRTC Millisecondes Très élevée Vidéo/audio, peer-to-peer

La bonne approche est la plus simple qui répond à votre exigence de latence. Commencez par le polling. Si les utilisateurs se plaignent de données périmées, passez à SSE. Si vous avez besoin de communication bidirectionnelle, utilisez les WebSockets. Chaque montée en complexité devrait être justifiée par un besoin utilisateur concret, pas par une exigence future hypothétique.

Recommandations pratiques

Après avoir construit des fonctionnalités temps réel sur plusieurs projets, voici les patterns auxquels je reviens constamment :

Par défaut, SSE pour le temps réel unidirectionnel. La plupart des fonctionnalités temps réel vont du serveur vers le client : mises à jour de commandes, flux de notifications, flux de données en direct. SSE gère tout cela avec moins de complexité que les WebSockets.

Utilisez les WebSockets uniquement pour les besoins bidirectionnels. Chat, édition collaborative, interactions multijoueurs — ceux-ci ont véritablement besoin de WebSockets. Si votre client ne fait que recevoir des données, SSE est plus simple.

Implémentez toujours la reconnexion avec backoff. Les connexions tombent. Les réseaux changent. Les serveurs redémarrent. Votre client doit gérer cela gracieusement, et votre serveur doit survivre à une tempête de reconnexions.

Envoyez des diffs, pas l'état complet. Au lieu d'envoyer la liste complète des commandes chaque fois qu'une commande change, envoyez juste la commande modifiée. Le client applique le diff à son état local. Cela réduit la bande passante et le traitement des deux côtés.

Ayez un fallback. Si votre connexion WebSocket échoue et ne peut pas se reconnecter, l'application devrait toujours être fonctionnelle. Un indicateur "dernière mise à jour il y a 30 secondes" avec rafraîchissement manuel est mieux qu'un écran vide.

Surveillez les compteurs de connexions en production. Les connexions WebSocket consomment des ressources serveur (mémoire, descripteurs de fichiers). Un pic soudain de connexions — dû à un bug client causant des reconnexions rapides, par exemple — peut faire tomber votre serveur. Alertez sur les anomalies de compteur de connexions.

L'objectif n'est pas d'avoir l'architecture temps réel la plus sophistiquée. L'objectif est de livrer les données aux utilisateurs assez rapidement pour que l'application semble réactive, avec le minimum de complexité d'infrastructure qui atteint ce ressenti.

DU

Danil Ulmashev

Full Stack Developer

Intéressé par une collaboration ?