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. */

      /* Sized off both axes so the 4:3 frame never overflows a short, wide
         stage: 126vh caps the height at 94.5vh. The 500px ceiling keeps the
         type from ballooning on a large frame — the whole diagram is one
         480-unit viewBox, so every label scales with this width. */
      .fanout {
        width: min(500px, 98vw, 126vh);
        aspect-ratio: 4 / 3;
      }

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

      text {
        font-family:
          ui-monospace,
          'SF Mono',
          Menlo,
          Consolas,
          monospace;
        /* User units, not CSS px: rendered size is 18.5 x (frame width / 480),
           i.e. 11.8px on the narrowest frame and 19.3px on the widest. Fluid
           by construction — no breakpoint, no jump. */
        font-size: 18.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: 17.5px;
        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: the packets stop travelling and the scene never
         dissolves — the diagram stays permanently settled, every subscriber
         delivered and acknowledged. But settled is not stopped: the topic
         glows softly, then each subscriber and its latency label brighten in
         the same staggered order the full version delivers them (41ms, 58ms,
         76ms, 94ms), and the tally brightens last. Same claim — one event,
         four subscribers, no two on the same tick — told as a slow 9s breath
         in opacity alone, at 1.25x the full cycle, with no reset to hide: the
         curves start and end on the same dim value. The travel animations are
         still cancelled by id, not by class: an id rule set them, so a class
         rule here would lose the cascade. */
      @media (prefers-reduced-motion: reduce) {
        #scene,
        #ingress,
        .fan,
        .ack,
        #ack-home,
        #emit,
        #publisher {
          animation: none;
        }

        #emit,
        .pulse {
          opacity: 0;
        }

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

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

        .lat,
        #tally {
          transform: none;
        }

        /* The topic never stops receiving: a slow clay wash at the head of
           each breath, well short of the flash the full version fires. */
        @keyframes topic-glow-reduced {
          0%,
          22%,
          100% {
            opacity: 0;
          }
          8% {
            opacity: 0.38;
          }
        }

        #topic-flash {
          opacity: 0;
          transform: none;
          animation: topic-glow-reduced 9s var(--ease-in-out) infinite;
        }

        @keyframes settled-breathe-reduced {
          0%,
          100% {
            opacity: 0.45;
          }
          45% {
            opacity: 1;
          }
        }

        @keyframes label-breathe-reduced {
          0%,
          100% {
            opacity: 0.4;
          }
          45% {
            opacity: 1;
          }
        }

        #sub-1,
        #sub-2,
        #sub-3,
        #sub-4 {
          animation: settled-breathe-reduced 9s var(--ease-in-out) infinite both;
        }

        #lat-1,
        #lat-2,
        #lat-3,
        #lat-4 {
          animation: label-breathe-reduced 9s var(--ease-in-out) infinite both;
        }

        /* The stagger, kept: fastest ack leads, slowest trails. */
        #sub-1,
        #lat-1 {
          animation-delay: 0.55s;
        }
        #sub-3,
        #lat-3 {
          animation-delay: 0.9s;
        }
        #sub-2,
        #lat-2 {
          animation-delay: 1.25s;
        }
        #sub-4,
        #lat-4 {
          animation-delay: 1.65s;
        }

        /* The count lands after the four it counts. */
        #tally {
          animation: label-breathe-reduced 9s var(--ease-in-out) 2.2s infinite both;
        }
      }
    </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" />

          <!-- Name sits 5 units above its node's centre line, latency 14 below:
               19 units apart, enough that the name's descenders clear the tops
               of "ack NNms" at this type size, with the pair still balanced on
               the node it labels. -->
          <text x="378" y="79" class="strong">cache</text>
          <text x="378" y="143" class="strong">search</text>
          <text x="378" y="207" class="strong">email</text>
          <text x="378" y="271" class="strong">audit</text>

          <text id="lat-1" class="lat" x="378" y="98">ack 41ms</text>
          <text id="lat-2" class="lat" x="378" y="162">ack 76ms</text>
          <text id="lat-3" class="lat" x="378" y="226">ack 58ms</text>
          <text id="lat-4" class="lat" x="378" y="290">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>