Table of contents progress

Pro

A table of contents that reads along with you: a line bends through the nested headings, fills as you scroll, and carries a marker to the current section, with smooth jumps and a time-left countdown.

Live · keyboard ready

How we cut checkout cold starts by 61 percent

Why cold starts mattered

Every checkout on our platform runs a pricing function before the payment sheet appears. On a warm instance it answers in 38 ms. On a cold one it took 1.9 seconds at the 95th percentile, and in March cold instances served one request in nine.

Those slow requests were not spread evenly. They clustered on the first checkout after a deploy and on regions that sleep overnight, which is exactly when a merchant is most likely to be watching.

Measuring the boot

We could not fix what we could not see, so the first week went into tracing. Each instance now reports four timestamps: process start, runtime ready, first import resolved and first response written.

Sampling every cold request, instead of one in a hundred like the rest of our traces, gave us 40,000 boots in the first three days. That was enough to split the numbers by region, deploy and memory size.

Tracing every phase

We added the timestamps to the existing request log instead of a new pipeline. That kept the change to 40 lines and let us compare cold and warm requests in the same query.

boot.mark("runtime-ready");
const pricing = await import("./pricing");
boot.mark("first-import");

Where the time went

Runtime startup was only 180 ms. Almost everything else was spent before our own code ran: resolving and evaluating 1,400 modules, most of them pulled in by two SDKs we used for a single call each.

Memory size barely mattered. Doubling it bought 90 ms, which told us the boot was waiting on the file system rather than the processor.

Module resolution

Resolution alone cost 610 ms, because every import walked up six directories looking for a package manifest. Bundling the function into one file removed the walk entirely.

Evaluation was the other half. Several SDKs ran setup code at import time, opening connection pools and reading configuration files the pricing path never touched.

Loading lazily

The bundle fixed resolution, but evaluation was still slow. The answer was to stop evaluating code a request might never need.

Splitting the bundle

We split the function into a small entry that handles the common path and three chunks for tax, currency conversion and fraud checks. Only one checkout in five needs any of them.

The entry went from 2.3 MB to 190 KB. Chunks load on first use and stay cached for the life of the instance.

Warming the cache

For the regions that sleep overnight we now send one synthetic checkout ten minutes before local business hours. It costs less than a dollar a month and keeps the first real customer off a cold instance.

We tried keeping instances warm around the clock first. It halved cold starts but tripled the bill for the quietest regions, and the synthetic checkout got most of the benefit for almost none of the cost.

Results

Cold starts dropped from 1.9 seconds to 740 ms at the 95th percentile, a 61 percent cut, and cold instances now serve one request in forty. Warm latency did not move.

Checkout abandonment on first requests after a deploy fell from 6.1 to 4.4 percent over the following month.

What we would do differently

Trace first. We spent two weeks tuning the runtime before a single trace showed us that the runtime was never the problem.

And keep an eye on dependencies you use for one call. Two of ours cost more at boot than the rest of the function combined.

  • A documentation page, guide or blog post long enough that readers jump between sections.
  • An engineering write up or policy where knowing how much is left helps readers commit to it.
  • Any long article in its own scroll container, such as a help panel or a reader view.
  • Use tabs when sections are alternatives rather than parts of one document.
  • Use scroll-progress-ring for a compact overall progress indicator without navigation.
  • Skip it for pages with fewer than four headings; a plain list of anchors is enough.

Installation

Pro source and install commands unlock with a Pro plan.

example.tsx
import { TocProgress } from "@/components/arc/toc-progress/toc-progress"; const headings = [  { id: "overview", label: "Overview" },  { id: "install", label: "Installation" },  { id: "install-cli", label: "With the CLI", level: 2 },  { id: "install-manual", label: "By hand", level: 2 },  { id: "configure", label: "Configuration" },] as const; export function DocsPage({ children }: { children: React.ReactNode }) {  return (    <div className="page">      <article>{children}</article>      <aside className="toc">        {/* Sticky header is 64px tall: headings count as reached just below it. */}        <TocProgress items={[...headings]} offset={88} onNavigate={id => history.replaceState(null, "", `#${id}`)} />      </aside>    </div>  );}

TocProgress

A table of contents that reads along with you. A thin line runs down the headings and bends through their indentation with a smooth S between levels. The part you have read fills in with the accent as you scroll, section by section, and a marker rides the tip of the line, lighting each heading as it arrives. Choosing a heading scrolls the article there smoothly while the line traces the way, and the time left beside the title counts down, then reads Finished.

PropTypeDefaultDescription
itemsRequiredTocProgressItem[]–Headings in document order, each { id, label, level?: 1 | 2 | 3 }. id is the id of the heading element in the article; level sets the lane the line bends into and the label indent.
scrollContainerRefObject<HTMLElement | null> | HTMLElement | null–The element that scrolls the article. Defaults to the nearest scrolling ancestor of the first heading, or the page when there is none.
offsetnumber96Distance in px from the top of the scroll viewport at which a heading counts as reached, usually the height of a sticky header plus a little air. Headings scroll to this line when chosen.
activeIdstring–The current heading, controlled. By default the current heading follows the reading position; the line keeps following the scroll either way.
onActiveChange(id: string) => void–Called with the heading id when the marker reaches a new heading, while reading or while a chosen heading scrolls into place.
onNavigate(id: string) => void–Called with the heading id when a heading in the list is chosen, before the article scrolls. Use it to update the URL hash or analytics.
readingTimenumber–Reading time of the whole article in minutes. Defaults to an estimate from the words in the scroll container at 230 words a minute.
titlestring"On this page"Heading above the list and the accessible name of the navigation. Pass an empty string to hide it.
showTimeLeftbooleantrueShows the minutes left beside the title, rolling to each new value and ending on Finished with a check.
labelstring"On this page"Accessible name of the navigation when title is empty.
classNamestring–Class on the root element. Set --toc-accent to recolor the read line and the marker.
styleCSSProperties–Inline styles on the root element.
Tab
Moves through the headings in the list; each is a link.
Arrow DownorArrow Up
Moves focus to the next or previous heading in the list.
HomeorEnd
Moves focus to the first or last heading in the list.
Enter
Scrolls the article to the heading and moves focus to it without a second scroll jump, so reading and tabbing continue from there.
  • The list is a nav landmark named by the title, with an ordered list of links whose href points at each heading, so it works as plain anchors before hydration.
  • The current heading carries aria-current="location"; read, current and upcoming headings also differ in text contrast, never by the line alone.
  • Choosing a heading gives the target heading tabindex="-1" if it has none and focuses it with preventScroll, so screen reader and keyboard users land where the article scrolled.
  • The line, the fill and the marker are one SVG hidden from assistive technology. The time left is plain text beside the title and is not announced on every change.
  • Pointer focus draws no ring; keyboard focus shows Arc's shared focus ring. A focused heading shows a quiet fill.
  • The reading position is a section index plus how far through that section you are, from where its heading crosses the offset line to where the next one does. It follows the scroll on a critically damped spring, so a wheel step glides instead of jumping and the marker never points past the section you are in.
  • Each section fills the stretch of line from its heading to the next, bends included, so the fill always meets a heading exactly when that heading is reached. The marker rides the tip of the fill along the same points the line is drawn from.
  • Near the end of the article the reading line eases from the offset down to the bottom edge of the viewport, so short last sections are still reached when the scroll bottoms out.
  • A chosen heading scrolls smoothly and the line traces there through every section in between. If the heading cannot reach the top because the article ends, it stays current until the reader scrolls on.
  • Heading colors change over 240ms. The time left rolls up to its new value with a short blur, on the snappy spring, and ends on Finished.
  • With reduced motion the fill and marker follow the scroll directly, chosen headings jump into place, colors change at once and the time left crossfades.
  • The list takes the width of its column and labels wrap; the line follows each row's measured height, so wrapped labels keep the bends between rows.
  • Rows, the line and the heading positions are re-measured with ResizeObserver whenever the list, the article or the scroll container resizes, including font loading and images.
  • Works with the page scroll or any scroll container, including one inside a preview frame or a dialog.
  • Touch targets are the full row width; on narrow screens place the component above the article.
  • Scroll handling is passive and does arithmetic on cached heading positions; layout is read only when something resizes.
  • The line is a single polyline path and its fill is the same path with a stroke dash, animated through motion values; the marker is one transform. React renders only when the current heading or the rounded time left changes.
  • Keep items to a few dozen headings; the line is rebuilt in one pass when the list resizes.

Notes for AI

Give your coding assistant the Markdown reference instead of screenshots.

  • Use it beside long form content with real headings: documentation pages, engineering write ups, guides, policies and changelogs. Pass the heading ids that already exist in the article.
  • Keep to two levels for most articles, three at most; the line bends at every level change, so alternating levels on every row makes it zigzag.
  • Set offset to the height of any sticky header plus 16 to 32px, and use the same value as scroll-margin-top on the headings for anchor links.
  • When the article scrolls inside its own container, pass scrollContainer; otherwise it finds the nearest scrolling ancestor of the first heading, or the page.
  • Pass readingTime when you already know it; the default estimate counts every word in the scroll container, including code.
  • Put it in a sticky aside on wide screens. On phones, show it above the article or in a sheet rather than beside it.

The full library index for assistants is at /llms.txt.