# Preconnect to Asset Domains

**SHOULD** · **ID:** `performance-preconnect-asset-domains` · **Category:** performance
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/performance-preconnect-asset-domains

> SHOULD: Add `<link rel="preconnect">` for every CDN and asset origin the page is certain to hit (with `crossorigin` for fonts and CORS-fetched assets) so DNS, TCP, and TLS overlap with HTML parsing. Only preconnect to origins you will definitely use — each holds an open socket.

Add <link rel="preconnect"> for every CDN and asset origin the page will hit

The first request to a new origin cannot send a byte until DNS resolution, the TCP handshake and the TLS negotiation all complete — frequently more wall-clock time than the download itself, and it is paid serially at the worst possible moment. rel="preconnect" starts those handshakes as soon as the HTML is parsed, in parallel with everything else, so the eventual request finds a warm connection. Preconnect only to origins you are certain to use (each one holds an open socket), and add crossorigin for font and CORS-fetched assets.

## Rule snippet

```tsx
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
```

## Bad — do not do this

`performance-preconnect-asset-domains-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

const ORIGINS = ['cdn.images.example', 'fonts.example', 'media.example'];

// Simulated network phases (ms). Real numbers vary; the ordering is what matters.
const DNS = 140;
const TLS = 200;
const DOWNLOAD = 160;

type Phase = 'idle' | 'dns' | 'tls' | 'download' | 'done';

const WIDTH: Record<Exclude<Phase, 'idle' | 'done'>, string> = {
  dns: 'w-10',
  tls: 'w-14',
  download: 'w-12',
};

export function PreconnectAssetDomainsBad() {
  const [phases, setPhases] = useState<Phase[]>(() => ORIGINS.map(() => 'idle'));
  const [elapsed, setElapsed] = useState<number | null>(null);
  const timers = useRef<number[]>([]);

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const run = () => {
    timers.current.forEach(clearTimeout);
    timers.current = [];
    setElapsed(null);
    setPhases(ORIGINS.map(() => 'idle'));

    const t0 = performance.now();
    const set = (i: number, phase: Phase) =>
      setPhases((prev) => prev.map((p, idx) => (idx === i ? phase : p)));

    ORIGINS.forEach((_, i) => {
      // Cold connection: the browser has to do DNS + TLS before it can ask for a single byte.
      timers.current.push(window.setTimeout(() => set(i, 'dns'), 0));
      timers.current.push(window.setTimeout(() => set(i, 'tls'), DNS));
      timers.current.push(window.setTimeout(() => set(i, 'download'), DNS + TLS));
      timers.current.push(
        window.setTimeout(() => {
          set(i, 'done');
          if (i === ORIGINS.length - 1) setElapsed(Math.round(performance.now() - t0));
        }, DNS + TLS + DOWNLOAD)
      );
    });
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="font-mono text-xs text-muted-foreground">&lt;head&gt; — no preconnect</div>

        <div className="space-y-2">
          {ORIGINS.map((origin, i) => {
            const phase = phases[i];
            const reached = (p: Exclude<Phase, 'idle' | 'done'>) =>
              phase === 'done' ||
              (p === 'dns' && phase !== 'idle') ||
              (p === 'tls' && (phase === 'tls' || phase === 'download')) ||
              (p === 'download' && phase === 'download');

            return (
              <div key={origin} className="space-y-1">
                <div className="font-mono text-[11px] text-foreground">{origin}</div>
                <div className="flex h-3 gap-0.5">
                  {(['dns', 'tls', 'download'] as const).map((p) => (
                    <div
                      key={p}
                      className={`${WIDTH[p]} rounded-sm ${
                        reached(p)
                          ? p === 'download'
                            ? 'bg-primary'
                            : 'bg-error'
                          : 'bg-muted'
                      }`}
                      title={p}
                    />
                  ))}
                </div>
              </div>
            );
          })}
        </div>

        <div className="flex items-center gap-3">
          <button
            type="button"
            onClick={run}
            className="rounded-lg bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Request assets
          </button>
          <span className="text-xs tabular-nums text-muted-foreground">
            {elapsed === null ? '—' : `${elapsed} ms to first byte-to-done`}
          </span>
        </div>
        <div className="text-[11px] text-muted-foreground">
          Red = DNS + TLS handshake, blue = the actual download.
        </div>
      </div>
      <p className="text-xs text-error">
        Every asset origin pays a cold DNS + TLS handshake before the first byte. The handshake
        blocks — here it costs more wall time than the download itself. (Phases are simulated.)
      </p>
    </div>
  );
}
```

## Good — do this

`performance-preconnect-asset-domains-good`

```tsx
import { useEffect, useRef, useState } from 'react';

const ORIGINS = ['cdn.images.example', 'fonts.example', 'media.example'];

// Same simulated phase costs as the bad example.
const DNS = 140;
const TLS = 200;
const DOWNLOAD = 160;

type Phase = 'cold' | 'warming' | 'warm' | 'download' | 'done';

export function PreconnectAssetDomainsGood() {
  const [phases, setPhases] = useState<Phase[]>(() => ORIGINS.map(() => 'cold'));
  const [elapsed, setElapsed] = useState<number | null>(null);
  const timers = useRef<number[]>([]);

  // <link rel="preconnect"> warms DNS + TLS at page load, in parallel with everything else.
  useEffect(() => {
    const pending = timers.current;
    setPhases(ORIGINS.map(() => 'warming'));
    pending.push(window.setTimeout(() => setPhases(ORIGINS.map(() => 'warm')), DNS + TLS));
    return () => pending.forEach(clearTimeout);
  }, []);

  const run = () => {
    setElapsed(null);
    const t0 = performance.now();
    setPhases(ORIGINS.map(() => 'download'));
    timers.current.push(
      window.setTimeout(() => {
        setPhases(ORIGINS.map(() => 'done'));
        setElapsed(Math.round(performance.now() - t0));
      }, DOWNLOAD)
    );
  };

  const warmed = phases.every((p) => p !== 'cold' && p !== 'warming');

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="space-y-3 rounded-lg border border-border bg-card p-4">
        <div className="space-y-0.5 font-mono text-[11px] text-muted-foreground">
          {ORIGINS.map((o) => (
            <div key={o}>&lt;link rel=&quot;preconnect&quot; href=&quot;https://{o}&quot;&gt;</div>
          ))}
        </div>

        <div className="space-y-2">
          {ORIGINS.map((origin, i) => {
            const phase = phases[i];
            const handshakeDone = phase === 'warm' || phase === 'download' || phase === 'done';
            const downloaded = phase === 'download' || phase === 'done';
            return (
              <div key={origin} className="space-y-1">
                <div className="font-mono text-[11px] text-foreground">{origin}</div>
                <div className="flex h-3 gap-0.5">
                  <div
                    className={`w-24 rounded-sm ${handshakeDone ? 'bg-success' : 'bg-muted'}`}
                    title="DNS + TLS, done before you asked"
                  />
                  <div
                    className={`w-12 rounded-sm ${downloaded ? 'bg-primary' : 'bg-muted'}`}
                    title="download"
                  />
                </div>
              </div>
            );
          })}
        </div>

        <div className="flex items-center gap-3">
          <button
            type="button"
            onClick={run}
            disabled={!warmed}
            className="rounded-lg bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 disabled:opacity-50 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            Request assets
          </button>
          <span className="text-xs tabular-nums text-muted-foreground">
            {!warmed
              ? 'warming connections…'
              : elapsed === null
                ? 'connections warm — ready'
                : `${elapsed} ms to first byte-to-done`}
          </span>
        </div>
        <div className="text-[11px] text-muted-foreground">
          Green = handshake already completed at page load, blue = the actual download.
        </div>
      </div>
      <p className="text-xs text-success">
        Preconnect moves DNS + TLS off the critical path, so requesting an asset costs only the
        download. Same bytes, a whole handshake sooner. (Phases are simulated.)
      </p>
    </div>
  );
}
```

## References

- [rel=preconnect (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/preconnect)
- [Preconnect and dns-prefetch](https://web.dev/articles/preconnect-and-dns-prefetch)
