Back to the library

Fan-out choreography

One event travels into a topic, splits into four deliveries that reach their subscribers on slightly different ticks, and each subscriber sends an acknowledgment back down the same wire with its own latency. Packets are a single dash riding a path normalised to pathLength="100", so position is nothing but stroke-dashoffset — compositor-cheap, no per-frame geometry; ingress uses ease-out because the arrival is the moment being read, while fan-out and ack use ease-in-out so each packet launches and lands. Every keyframe percentage is an absolute position in one 7.2s beat rather than a per-element delay, which is what lets the four subscribers fall out of step with each other and still reset together under the dissolve. Reach for it when explaining pub/sub, event buses, webhook delivery, or replication — anywhere the point is that one write becomes many, and that "many" is never simultaneous.

conceptpub-submessagingstaggersvgdistributed-systems

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Fan-out</title>
    <style>
      :root {
        --ivory: #faf9f5;
        --paper: #ffffff;
        --slate: #141413;
        --clay: #d97757;
        --clay-d: #b85c3e;
        --oat: #e3dacc;
        --olive: #788c5d;
        --rust: #b04a3f;
        --gray-300: #d1cfc5;
        --gray-500: #87867f;

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

        /* One cycle. Every keyframe percentage below is an absolute position in
           this 7.2s beat, so the four subscribers can be out of step with each
           other and still land back at rest together before the replay. */
        --cycle: 7.2s;
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      /* ─── KEEP FROM HERE ───────────────────────────────────────────────
         .fanout (the 4:3 frame), the <svg> inside it, and every rule below
         are the animation. The :root tokens, body centring, and the
         reduced-motion block travel with it. Everything above is demo
         chrome you can drop. */

      .fanout {
        width: min(560px, 92vw);
        aspect-ratio: 4 / 3;
      }

      .fanout svg {
        display: block;
        width: 100%;
        height: 100%;
      }

      text {
        font-family:
          ui-monospace,
          'SF Mono',
          Menlo,
          Consolas,
          monospace;
        font-size: 10.5px;
        letter-spacing: 0.06em;
        fill: var(--gray-500);
      }

      text.strong {
        fill: var(--slate);
      }

      /* The whole scene breathes out and back in once per cycle. Blur on the
         way out keeps the reset from reading as two overlapping diagrams. */
      #scene {
        animation: scene-cycle var(--cycle) linear infinite;
      }

      @keyframes scene-cycle {
        0%,
        84% {
          opacity: 1;
          filter: blur(0);
        }
        91%,
        95% {
          opacity: 0;
          filter: blur(5px);
        }
        100% {
          opacity: 1;
          filter: blur(0);
        }
      }

      .wire {
        fill: none;
        stroke: var(--gray-300);
        stroke-width: 1.25;
      }

      /* A single 7-unit dash riding a path normalised to pathLength="100":
         the packet's position is stroke-dashoffset, which is compositor-cheap
         and needs no per-frame geometry. It parks fully clear of both ends
         (offset 14 / -110) rather than flush against them — a round linecap
         sitting exactly on the endpoint would leave a stray dot behind. */
      .pulse {
        fill: none;
        stroke-width: 3;
        stroke-linecap: round;
        stroke-dasharray: 7 200;
        stroke-dashoffset: -110;
      }

      .pulse.out {
        stroke: var(--clay);
      }

      .pulse.back {
        stroke: var(--olive);
      }

      @keyframes travel-out {
        0% {
          stroke-dashoffset: 14;
        }
        11.8%,
        100% {
          stroke-dashoffset: -110;
        }
      }

      @keyframes travel-back {
        0% {
          stroke-dashoffset: -110;
        }
        11.8%,
        100% {
          stroke-dashoffset: 14;
        }
      }

      /* Ingress: the event decelerating into the topic — ease-out, because the
         arrival is the moment being read. */
      #ingress {
        animation: travel-out var(--cycle) var(--ease-out) 0.15s infinite both;
      }

      /* Fan-out and acknowledgement are travel across the frame, so ease-in-out:
         each packet launches and lands. The delays are the whole point — no two
         subscribers are served on the same tick. */
      .fan {
        animation: travel-out var(--cycle) var(--ease-in-out) infinite both;
      }
      #fan-1 {
        animation-delay: 0.92s;
      }
      #fan-2 {
        animation-delay: 1.05s;
      }
      #fan-3 {
        animation-delay: 0.98s;
      }
      #fan-4 {
        animation-delay: 1.18s;
      }

      .ack {
        animation: travel-back var(--cycle) var(--ease-in-out) infinite both;
      }
      #ack-1 {
        animation-delay: 2.45s;
      }
      #ack-2 {
        animation-delay: 2.8s;
      }
      #ack-3 {
        animation-delay: 2.62s;
      }
      #ack-4 {
        animation-delay: 3.05s;
      }

      #ack-home {
        animation: travel-back var(--cycle) var(--ease-out) 3.95s infinite both;
      }

      /* ── publisher ───────────────────────────────────────────────── */
      #emit {
        fill: none;
        stroke: var(--clay);
        stroke-width: 1.5;
        transform-box: fill-box;
        transform-origin: center;
        animation: emit var(--cycle) var(--ease-out) infinite;
      }

      @keyframes emit {
        0% {
          opacity: 0.55;
          transform: scale(1);
        }
        9%,
        100% {
          opacity: 0;
          transform: scale(2.3);
        }
      }

      #publisher {
        fill: var(--paper);
        stroke: var(--clay);
        stroke-width: 1.75;
        animation: publisher-settle var(--cycle) linear infinite;
      }

      @keyframes publisher-settle {
        0%,
        64% {
          stroke: var(--clay);
        }
        66%,
        93% {
          stroke: var(--olive);
        }
        94%,
        100% {
          stroke: var(--clay);
        }
      }

      /* ── topic ───────────────────────────────────────────────────── */
      #topic-flash {
        fill: var(--clay);
        transform-box: fill-box;
        transform-origin: center;
        animation: topic-flash var(--cycle) var(--ease-out) infinite;
      }

      @keyframes topic-flash {
        0%,
        8% {
          opacity: 0;
          transform: scaleX(1);
        }
        10.5% {
          opacity: 1;
          transform: scaleX(1.5);
        }
        18%,
        100% {
          opacity: 0;
          transform: scaleX(1);
        }
      }

      /* ── subscribers ─────────────────────────────────────────────── */
      .sub {
        fill: var(--oat);
        stroke: var(--gray-300);
        stroke-width: 1.5;
        transform-box: fill-box;
        transform-origin: center;
      }

      /* Four near-identical curves rather than one shared curve plus delays:
         a delay would drag each subscriber's reset past the dissolve and into
         the next event. Absolute percentages keep the reset collective. */
      #sub-1 {
        animation: deliver-1 var(--cycle) var(--ease-out) infinite;
      }
      #sub-2 {
        animation: deliver-2 var(--cycle) var(--ease-out) infinite;
      }
      #sub-3 {
        animation: deliver-3 var(--cycle) var(--ease-out) infinite;
      }
      #sub-4 {
        animation: deliver-4 var(--cycle) var(--ease-out) infinite;
      }

      @keyframes deliver-1 {
        0%,
        24.5% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
        26% {
          fill: var(--clay);
          stroke: var(--clay);
          transform: scale(1.2);
        }
        30%,
        93% {
          fill: var(--olive);
          stroke: var(--olive);
          transform: scale(1);
        }
        94%,
        100% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
      }

      @keyframes deliver-2 {
        0%,
        26.3% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
        27.8% {
          fill: var(--clay);
          stroke: var(--clay);
          transform: scale(1.2);
        }
        31.8%,
        93% {
          fill: var(--olive);
          stroke: var(--olive);
          transform: scale(1);
        }
        94%,
        100% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
      }

      @keyframes deliver-3 {
        0%,
        25.3% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
        26.8% {
          fill: var(--clay);
          stroke: var(--clay);
          transform: scale(1.2);
        }
        30.8%,
        93% {
          fill: var(--olive);
          stroke: var(--olive);
          transform: scale(1);
        }
        94%,
        100% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
      }

      @keyframes deliver-4 {
        0%,
        28.1% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
        29.6% {
          fill: var(--clay);
          stroke: var(--clay);
          transform: scale(1.2);
        }
        33.6%,
        93% {
          fill: var(--olive);
          stroke: var(--olive);
          transform: scale(1);
        }
        94%,
        100% {
          fill: var(--oat);
          stroke: var(--gray-300);
          transform: scale(1);
        }
      }

      /* Per-subscriber latency, revealed at the instant that subscriber acks. */
      .lat {
        font-size: 9px;
        fill: var(--gray-500);
        opacity: 0;
      }
      #lat-1 {
        animation: lat-1 var(--cycle) var(--ease-out) infinite;
      }
      #lat-2 {
        animation: lat-2 var(--cycle) var(--ease-out) infinite;
      }
      #lat-3 {
        animation: lat-3 var(--cycle) var(--ease-out) infinite;
      }
      #lat-4 {
        animation: lat-4 var(--cycle) var(--ease-out) infinite;
      }

      @keyframes lat-1 {
        0%,
        34% {
          opacity: 0;
          transform: translateY(3px);
        }
        38%,
        93% {
          opacity: 1;
          transform: translateY(0);
        }
        94%,
        100% {
          opacity: 0;
          transform: translateY(3px);
        }
      }

      @keyframes lat-2 {
        0%,
        38.9% {
          opacity: 0;
          transform: translateY(3px);
        }
        42.9%,
        93% {
          opacity: 1;
          transform: translateY(0);
        }
        94%,
        100% {
          opacity: 0;
          transform: translateY(3px);
        }
      }

      @keyframes lat-3 {
        0%,
        36.4% {
          opacity: 0;
          transform: translateY(3px);
        }
        40.4%,
        93% {
          opacity: 1;
          transform: translateY(0);
        }
        94%,
        100% {
          opacity: 0;
          transform: translateY(3px);
        }
      }

      @keyframes lat-4 {
        0%,
        42.4% {
          opacity: 0;
          transform: translateY(3px);
        }
        46.4%,
        93% {
          opacity: 1;
          transform: translateY(0);
        }
        94%,
        100% {
          opacity: 0;
          transform: translateY(3px);
        }
      }

      /* The settled state: the beat the loop holds before dissolving. */
      #tally {
        opacity: 0;
        fill: var(--olive);
        animation: tally var(--cycle) var(--ease-out) infinite;
      }

      @keyframes tally {
        0%,
        66% {
          opacity: 0;
          transform: translateY(3px);
        }
        70%,
        93% {
          opacity: 1;
          transform: translateY(0);
        }
        94%,
        100% {
          opacity: 0;
          transform: translateY(3px);
        }
      }

      /* Reduced motion: no travel, no dissolve, no replay. The diagram is
         simply shown already settled — every subscriber delivered and
         acknowledged, latencies and tally legible. The per-node animations are
         cancelled by id, not by class: an id rule set them, so a class rule
         here would lose the cascade and leave the diagram stuck at idle. */
      @media (prefers-reduced-motion: reduce) {
        #scene,
        #ingress,
        .fan,
        .ack,
        #ack-home,
        #emit,
        #publisher,
        #topic-flash,
        #sub-1,
        #sub-2,
        #sub-3,
        #sub-4,
        #lat-1,
        #lat-2,
        #lat-3,
        #lat-4,
        #tally {
          animation: none;
        }

        #emit,
        #topic-flash,
        .pulse {
          opacity: 0;
        }

        #publisher {
          stroke: var(--olive);
        }

        .sub {
          fill: var(--olive);
          stroke: var(--olive);
        }

        .lat,
        #tally {
          opacity: 1;
          transform: none;
        }
      }
    </style>
  </head>
  <body>
    <div class="fanout">
      <svg viewBox="0 0 480 360" xmlns="http://www.w3.org/2000/svg" role="img"
        aria-label="One event published to a topic, fanned out to four subscribers with staggered delivery, each acknowledging back">
        <g id="scene">
          <!-- publisher -->
          <circle id="emit" cx="66" cy="180" r="17" />
          <circle id="publisher" cx="66" cy="180" r="17" />
          <circle cx="66" cy="180" r="4.5" fill="var(--clay)" />
          <text x="66" y="216" text-anchor="middle">publisher</text>

          <!-- ingress -->
          <path class="wire" d="M 86 180 L 197 180" />
          <path id="ingress" class="pulse out" d="M 86 180 L 197 180" pathLength="100" />
          <path id="ack-home" class="pulse back" d="M 86 180 L 197 180" pathLength="100" />

          <!-- topic -->
          <text x="211" y="60" text-anchor="middle" class="strong">order.created</text>
          <rect x="198" y="72" width="26" height="216" rx="13" fill="var(--slate)" />
          <rect id="topic-flash" x="198" y="72" width="26" height="216" rx="13" />
          <text x="211" y="306" text-anchor="middle">topic</text>

          <!-- fan-out wires -->
          <path class="wire" d="M 224 180 C 276 180, 292 84, 344 84" />
          <path class="wire" d="M 224 180 C 276 180, 292 148, 344 148" />
          <path class="wire" d="M 224 180 C 276 180, 292 212, 344 212" />
          <path class="wire" d="M 224 180 C 276 180, 292 276, 344 276" />

          <path id="fan-1" class="pulse fan out" d="M 224 180 C 276 180, 292 84, 344 84" pathLength="100" />
          <path id="fan-2" class="pulse fan out" d="M 224 180 C 276 180, 292 148, 344 148" pathLength="100" />
          <path id="fan-3" class="pulse fan out" d="M 224 180 C 276 180, 292 212, 344 212" pathLength="100" />
          <path id="fan-4" class="pulse fan out" d="M 224 180 C 276 180, 292 276, 344 276" pathLength="100" />

          <path id="ack-1" class="pulse ack back" d="M 224 180 C 276 180, 292 84, 344 84" pathLength="100" />
          <path id="ack-2" class="pulse ack back" d="M 224 180 C 276 180, 292 148, 344 148" pathLength="100" />
          <path id="ack-3" class="pulse ack back" d="M 224 180 C 276 180, 292 212, 344 212" pathLength="100" />
          <path id="ack-4" class="pulse ack back" d="M 224 180 C 276 180, 292 276, 344 276" pathLength="100" />

          <!-- subscribers -->
          <circle id="sub-1" class="sub" cx="358" cy="84" r="13" />
          <circle id="sub-2" class="sub" cx="358" cy="148" r="13" />
          <circle id="sub-3" class="sub" cx="358" cy="212" r="13" />
          <circle id="sub-4" class="sub" cx="358" cy="276" r="13" />

          <text x="378" y="82" class="strong">cache</text>
          <text x="378" y="146" class="strong">search</text>
          <text x="378" y="210" class="strong">email</text>
          <text x="378" y="274" class="strong">audit</text>

          <text id="lat-1" class="lat" x="378" y="95">ack 41ms</text>
          <text id="lat-2" class="lat" x="378" y="159">ack 76ms</text>
          <text id="lat-3" class="lat" x="378" y="223">ack 58ms</text>
          <text id="lat-4" class="lat" x="378" y="287">ack 94ms</text>

          <!-- settled tally -->
          <text x="24" y="340">1 event</text>
          <text id="tally" x="456" y="340" text-anchor="end">4 delivered · 4 acked</text>
        </g>
      </svg>
    </div>
  </body>
</html>