# Signals

> What makes your CI slow, costly or busier than it needs to be, and which workflows it affects.

The **Signals** page, under Analytics, points out patterns in your CI that cost you time or money. Each signal has the same title every time, with the repos and workflows it affects listed underneath, most minutes first.

Signals are grouped by the question they answer:

- **Speed**: why is CI slow?
- **Cost**: why does CI cost this much?
- **Complexity**: is CI doing more than it needs to?

Below the signals, the [Breakdown](#breakdown) shows who and what uses your CI minutes.

Every area shows on the page, even when none of its signals came up. Signals that didn't come up are listed under their area, after **No sign of**, so you can see everything the page checks for. The ⓘ beside any signal explains it and links to its section here.

Click a row under any signal to see the runs behind it, newest first. Each run opens in [History](/docs/dashboard/activity#history) with its jobs, and on signals about one job, that job.

The page updates every hour. A signal shows up once it covers at least 30 minutes and 1% of all your job minutes in the range, so small one-offs stay off the page. Minutes and spend sit in separate columns. Spend uses the same list prices as the [Usage](/docs/dashboard/usage) page, so public repos and self-hosted runners count as free.

## Speed signals

Waiting for a runner and waiting on one job cost time rather than money, so those two signals leave the spend column empty.

### Waiting for a runner

These jobs waited more than a minute after GitHub queued them before a runner took them, grouped by their `runs-on` labels. Jobs wait when every runner with those labels is busy, or when the account reaches GitHub's limit on jobs running at once. Add a step to your [runner order](/docs/concepts/runner-order) so jobs go to the next runner that's free instead of waiting.

Click a runner's name to open its panel, with its jobs per day and recent jobs. When every waiting job ran on the same machine, the name opens that machine.

### One job sets the pace

This job ran alongside the others in its run and was still going after they all finished, so the run waited on it alone. The minutes are that wait. Jobs that wait for another job with `needs` aren't counted, since they start after it by design. Split the job, or compare its times on each runner on the [Performance](/docs/dashboard/performance) page.

### Flaky jobs

These jobs failed in one of their steps and then passed when the run was tried again, so the failure likely had nothing to do with the change. The minutes and spend are the failed attempts. Fixing the test or step that fails now and then saves the re-run and the wait for it.

### Runner failures

These jobs failed before any of their steps started, grouped by their `runs-on` labels. The runner was the problem, not your code. Turn on [automatic retries](/docs/concepts/routing#automatic-retries) to send them to another runner.

## Cost signals

### No concurrency limit

These runs kept going after someone pushed again to the same branch. The minutes count from when the newer run started. Your default branch is left out, since most teams want every commit there to finish.

Add a concurrency group with `cancel-in-progress` to the workflow, and GitHub cancels the older run when a newer one starts:

```yaml
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
```

### Same commit runs twice

The workflow ran for the same commit from both `push` and `pull_request`. The push runs are the extra ones, so their minutes and spend are what the page shows. Limit `push` to your default branch and the pull request run still covers every change.

### Short jobs rounded up

GitHub bills each job by the whole minute. These jobs finished in under a minute on a billed runner, so part of every minute went unused. The page shows that unused part. Lint and other quick checks can run as [optimized checks](/docs/concepts/optimized-checks) instead.

## Complexity signals

### Docs changes run CI

The workflow ran for a pull request that only changed docs: Markdown and other prose files, anything in a `docs` folder, or a license. For runs without a pull request, it goes by the title instead: commits titled as docs changes, such as `docs: fix a typo` in [Conventional Commits](https://www.conventionalcommits.org/). Workflows with "doc" in their name are left out, since building docs is their job. Add `paths-ignore` to the workflow's `push` and `pull_request` triggers so changes that only touch docs skip it:

```yaml
on:
  pull_request:
    paths-ignore:
      - "docs/**"
      - "**/*.md"
```

### Full matrix on every branch

The workflow started 10 or more jobs for a push or pull request on a branch other than your default, usually a wide matrix. All of those runs' minutes count. Running a smaller matrix before merge, and the full one on your default branch, still catches most problems early.

## Breakdown

The Breakdown splits all your job minutes and spend one way at a time:

- **Person**: who started the run.
- **Repository**.
- **Workflow**, with its repo.
- **Branch**.
- **Trigger**: push, pull request, schedule, manual or re-run.
- **Changed folder**: the folders a pull request changed, with their repo, such as `src` or `docs`. Inside folders like `apps` and `packages`, each project is its own folder, such as `apps/web`. Only pull request runs count here, and a run counts under every folder it changed, so the rows overlap.

Each view lists the 10 with the most minutes, then everything else in one row. Runs count once each, so a run with a 20-job matrix is still one run.

To know which folders a pull request changed, the dashboard reads the names of its files from GitHub once an hour. It keeps only the folders, never the files' contents.

### Filter the page

Click a row in the Breakdown to narrow the whole page to it: the signals, their rows and the other Breakdown views then only count that person's, repo's, workflow's, branch's, trigger's or folder's runs. Click a row in another view to narrow it further, and use the × beside a filter to remove it.

A filtered page also shows that filter's minutes per day, with the same number of days before as a dashed line, so you can see whether it's growing. Filtered pages cover up to the last 30 days.
