# The Locks & Limits screen

> Held unique and overlap locks, the dispatches they skipped, debounced jobs, 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-27

---

The **Locks & Limits** screen, at `/horizon/locks`, shows the parts of a Laravel queue that hold jobs back without failing them: the `ShouldBeUnique` and `WithoutOverlapping` locks currently held, the dispatches those locks skipped, debounced jobs, 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`. |

## Held 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. |

### Overlap locks

Since 1.5 the card also lists the lock Skyline's `WithoutOverlapping` middleware holds while a job runs, with an **Overlap** badge. Its worst case used to be invisible: a worker killed mid-job by a timeout or an out-of-memory kill never runs the framework's cleanup, and without `expireAfter()` the lock never expires, so every later instance of the job is released or dropped for good. The row is held while its job is reserved, **Stranded** once the job is in any other state, and **Job gone** once Horizon no longer stores it. Overlap rows show no skip count, because the middleware releases jobs rather than skipping dispatches. Reading the lock's owner costs one extra cache read per run, only on a Redis queue with lock insights on.

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`, or `overlap_lock.released_from_dashboard` for an overlap lock, which is released with the same owner check.

With alerts on, the `stranded_lock` check reports a unique or overlap lock that has outlived its job for five minutes, so you hear about it before someone opens this screen. See [Alerts](https://boring-observability.dev/skyline/docs/alerts).

## 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.

## Debounced jobs

Laravel 13.6 added `#[DebounceFor]`. A debounced dispatch is queued with a delay, and when a worker picks it up after a newer dispatch has taken over, the framework deletes it without running it. Horizon recorded each of those deletes as a completed job. Skyline logs them as `job.debounced` and keeps them out of the completed log, and the **Debounced Jobs** card counts, per job class for the past hour and day, the dispatches queued, the ones superseded by a newer dispatch, and the ones the maximum wait forced through. It shows each job's window and max wait beside them. The card appears on Laravel 13.6 and later, or once there is data, and follows `lock_insights`.

## 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. The framework's `WithoutOverlapping` lock is not listed under Held locks either.

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.

### How do I find a stuck WithoutOverlapping lock?

If a worker is killed while a job holds its WithoutOverlapping lock, the lock is never released, and without expireAfter() it never expires, so every later instance of the job is released or dropped. Use Skyline's drop-in WithoutOverlapping from Laravel\Horizon\Middleware and the lock is listed on Locks & Limits with an Overlap badge while the job runs. It is flagged Stranded once the job is no longer running, and can be released from the dashboard after an owner check.
