# The Locks & Limits screen

> Held unique locks, the dispatches they skipped, and the jobs rate limiters and throttles held back.

Source: https://boring-observability.dev/skyline/docs/locks-and-limits
Section: Queue control — Skyline for Laravel documentation
Updated: 2026-09-15

---

The **Locks & Limits** screen, at `/horizon/locks`, shows the parts of a Laravel queue that hold jobs back without failing them: the `ShouldBeUnique` locks currently held, the dispatches those locks skipped, and the jobs that rate-limiting and overlap middleware released or dropped. None of these appear anywhere in Horizon, because a skipped dispatch never reaches the queue and a middleware release looks like any other release.

It is on by default and needs no setup for unique locks. Rate limiters and throttles report once you import Skyline's [drop-in middleware](#middleware).

## The summary row

| Figure | What it counts |
| --- | --- |
| Unique Locks Held | Locks in the index, with the number flagged beside it. |
| Dispatches Skipped (24h) | Dispatches discarded because the job's unique lock was held. |
| Jobs Held Back (24h) | Jobs a rate limiter, throttle or overlap lock released or dropped without running. |
| Throttled Exceptions (24h) | Exceptions caught by `ThrottlesExceptions`. |

## Unique locks

Each row is a unique lock a dispatch took on the default cache store: the job and its `uniqueId`, how long the lock has been held, when it expires (**Never** for a lock with no `uniqueFor`), a link to the job it was taken for, and how many dispatches it has skipped. Flagged locks sort first, and the **Only flagged** switch hides the rest.

| Flag | Meaning |
| --- | --- |
| **Stranded** | The job has completed or failed and the lock is still held. For a `ShouldBeUniqueUntilProcessing` job, the job has already started. Also set when a job refused on pickup left its lock behind. |
| **Job gone** | The job that took the lock is no longer stored by Horizon, and the lock is still held. |
| **No job** | No queued job was seen for the lock after 10 minutes. It may have been dispatched to a connection Horizon doesn't manage, rolled back with its transaction, or taken before lock tracking started. |
| **Taken elsewhere** | The lock is held, but not by the owner Horizon recorded, so something outside Horizon's view took it. |

A lock that could not be checked against its cache store is marked **Unverified**. While a lock is stranded, every dispatch of that job is skipped until the lock expires, or indefinitely if it has no TTL.

### Releasing a lock

The **Release** button asks for confirmation, then frees the lock so the next dispatch of the job is queued. The release is owner-checked: it only frees the lock if it is still held by the owner the page showed. If another dispatch has taken the lock since the page loaded, the request fails with `409` and the lock is left alone. A lock that was already free is reported as released already.

Releasing a lock while a copy of the job is still waiting lets a second copy be queued. For a `ShouldBeUniqueUntilProcessing` job, the waiting copy releases the lock again when it starts, even one a newer dispatch has taken. The confirmation says so for those jobs. Each release is logged as `unique_lock.released_from_dashboard`.

## Skipped dispatches

A dispatch discarded over a held lock is a silent no-op in Laravel. This table counts them per job for the past hour and the past day, with the last `uniqueId` and when it was last skipped. **Show recent** lists the 50 most recent discards with their timestamps.

A job with a steady skip count is usually working as intended. A job whose count climbs while nothing of it runs usually has a stranded lock, and its row in the table above will say so.

## Rate limits and throttles

One row per rate limiter, per job class using `ThrottlesExceptions`, and per job class using `WithoutOverlapping`. Each row shows past-hour and past-day counts of jobs released without running, jobs dropped without running, and exceptions the throttle caught. It also shows the configuration (the limit and its window, and any `releaseAfter()` or `expireAfter()`), the last exception a throttle caught, and the live count on the last limiter key a job was stopped by.

Counts are grouped by limiter name or job class, never by the per-user or per-tenant key a limiter was hit on, so a limiter keyed on user id is still one row.

### Drop-in middleware

Laravel's `RateLimited` and `ThrottlesExceptions` release a job with a plain `release()`, which Horizon cannot tell apart from a job releasing itself. `ThrottlesExceptions` also catches the job's exception, so no exception event fires and the error is lost. Skyline ships subclasses that record what happened. Swap the import and nothing else changes:

```php
use Laravel\Horizon\Middleware\RateLimited;
// was: use Illuminate\Queue\Middleware\RateLimited;

public function middleware(): array
{
    return [new RateLimited('backups')];
}
```

| Import from `Laravel\Horizon\Middleware` | What it adds |
| --- | --- |
| `RateLimited`, `RateLimitedWithRedis` | Releases are attributed as `rate_limited` and name the limiter and the limit that was hit. A `dontRelease()` drop is logged as `job.rate_limited_dropped` instead of passing for a completed job. |
| `ThrottlesExceptions`, `ThrottlesExceptionsWithRedis` | A release while the throttle is open is `exceptions_throttled`. A release after the job threw is `throttled_exception`, logged at warning with the exception's class, message and location, and shown under **Previous Attempts**. A job deleted through `deleteWhen()` is logged as `job.throttled_exception_deleted`. |
| `WithoutOverlapping` | Releases are attributed as `without_overlapping` and counted here. A `dontRelease()` drop is logged as `job.overlap_dropped`. |

Each subclass calls the framework's own `handle()` and reads the outcome, rather than copying its logic, so framework changes to how these middleware decide still apply. The framework's versions keep working unchanged; they don't report to this screen, and their releases keep the generic `released` reason. The release reasons and log lines are listed on [Job lifecycle logging](https://boring-observability.dev/skyline/docs/job-lifecycle-logging).

## Storage and cost

Everything is stored on Horizon's own Redis connection. Each unique dispatch and each lock release costs one write.

- **The lock index** is written when a lock is acquired and cleared when it is released. It is checked against the cache store whenever the screen loads, and in batches of 100 a minute by the master supervisor, so locks removed behind Horizon's back by `cache:clear` or eviction drop out. It holds at most 1,000 locks, dropping the soonest to expire first.
- **Counts** go into 10-minute buckets that expire after 25 hours. Only the 50 latest skipped dispatches are kept individually.
- **Writes never break a dispatch.** If a write to Redis fails, Skyline stops writing for 30 seconds and the job carries on as normal.

```php
// config/horizon.php
'lock_insights' => env('HORIZON_LOCK_INSIGHTS', true),
```

Set `HORIZON_LOCK_INSIGHTS=false` to store nothing. The screen then shows a banner, while the log lines and release reasons keep working.

## Limits

- **The default cache store only.** A job whose `uniqueVia()` keeps its lock on another store, a lock taken through the `Cache` facade, and a custom cache repository subclass are not tracked. The same limit applies to discard logging.
- **Only locks a dispatch takes are linked to a job.** A lock taken outside the dispatch path shows as **No job** after 10 minutes, or **Taken elsewhere** if it replaced one Horizon recorded.
- **Only Skyline's middleware report.** Jobs using the framework's `RateLimited`, `ThrottlesExceptions` or `WithoutOverlapping` don't appear under Rate limits and throttles, and neither do `Skip` or your own middleware.

For why a unique lock ends up stranded in the first place, and what Skyline releases automatically, see [Unique job locks](https://boring-observability.dev/skyline/docs/unique-job-locks). The API behind this screen is on the [HTTP API](https://boring-observability.dev/skyline/docs/api#locks) page.


## Common questions

### How can I see which Laravel unique job locks are held?

Horizon doesn't show cache locks. Skyline's Locks & Limits screen, at /horizon/locks, lists the unique locks dispatches took on the default cache store, with each lock's job, how long it has been held, its expiry and how many dispatches it skipped. Locks are flagged Stranded, Job gone, No job or Taken elsewhere when something is wrong.

### Why was my job released by RateLimited or ThrottlesExceptions without running?

Its limiter was exhausted, or the throttle was open after too many exceptions. The framework releases the job with a plain release(), so Horizon shows a generic release. Import Skyline's drop-in RateLimited, RateLimitedWithRedis, ThrottlesExceptions or ThrottlesExceptionsWithRedis from Laravel\Horizon\Middleware and each release carries a reason (rate_limited, exceptions_throttled or throttled_exception), is counted per limiter on Locks & Limits, and a caught exception is shown under Previous Attempts.

### Does lock tracking slow down dispatching?

Each unique dispatch and lock release costs one write to Horizon's Redis connection. A failed write never breaks the dispatch: Skyline pauses tracking writes for 30 seconds and carries on. Set HORIZON_LOCK_INSIGHTS=false to store nothing, while keeping the log lines and release reasons.
