---
title: "Non-cacheable Extbase plugin actions and developer responsibility"
manual: "TYPO3 Explained"
version: "main"
permalink: "https://docs.typo3.org/permalink/t3coreapi:extbase-caching-noncacheable@main"
source: "ExtensionArchitecture/Extbase/Caching/NonCacheable.rst"
rendered: "2026-09-24T12:36:55+00:00"
---

# Non-cacheable Extbase plugin actions and developer responsibility {#extbase-caching-noncacheable}

Consider a real project: a shop with a large product catalog and an extensive
filter form, whose visitors abandon a page that does not respond almost
immediately. Rendered fully uncached, the product list takes *tens of seconds*.
Rendered from cache, it comes back in a fraction of a second. That gap is not a
benchmark curiosity — on a shop it is the difference between a sale and a closed
tab. Performance is not an optimization to add later; it is the feature.

Because the filter runs over POST, none of TYPO3's built-in caching applies to
that list — the same situation as the first example below. The performance has to
be rebuilt by hand, with a custom two-level cache. This is the point the older
Extbase books quietly skipped: they told you to mark an action non-cacheable and
stopped there, as if that were the end of the story. It is the beginning of one.
**The moment the framework's caching no longer applies, keeping the page fast
becomes your job** — and, as that shop showed, the cost of getting it wrong is
measured in real money, not milliseconds.

By default every Extbase plugin action is cacheable: it runs once, and its
rendered output is stored in the page cache and reused for every following
visitor. Some actions cannot work that way — a form that must issue a fresh
token on every request, or a view that shows per-user or live data. For those,
Extbase lets you declare an action **non-cacheable**.

Declaring an action non-cacheable is a small change with a large consequence:
that action is rendered on *every* request, and the performance the page cache
gave you for free becomes your responsibility. This page explains how to make an
action non-cacheable, what it really costs, and how to avoid reaching for it when
a cheaper option exists.

**On this page**

-   [Declaring an Extbase plugin action non-cacheable](https://docs.typo3.org/permalink/t3coreapi:declaring-an-extbase-plugin-action-non-cacheable@main)
-   [What a non-cacheable Extbase action means: USER versus USER_INT](https://docs.typo3.org/permalink/t3coreapi:what-a-non-cacheable-extbase-action-means-user-versus-user-int@main)
-   [The cost of a non-cacheable Extbase action: it runs on every request](https://docs.typo3.org/permalink/t3coreapi:the-cost-of-a-non-cacheable-extbase-action-it-runs-on-every-request@main)
-   [When an Extbase action needs to be non-cacheable — and when it does not](https://docs.typo3.org/permalink/t3coreapi:when-an-extbase-action-needs-to-be-non-cacheable-and-when-it-does-not@main)
-   [Keeping a non-cached Extbase plugin fast](https://docs.typo3.org/permalink/t3coreapi:keeping-a-non-cached-extbase-plugin-fast@main)

## Declaring an Extbase plugin action non-cacheable {#extbase-caching-noncacheable-declare}

Non-cacheable actions are declared in the fourth argument of
`\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin()`, using the
same structure as the third (cacheable) argument: an array keyed by controller
class name, with a comma-separated list of action names as the value.

**EXT:my_extension/ext_localconf.php**

```php
<?php

use MyVendor\MyExtension\Controller\ConferenceController;
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;

defined('TYPO3') or die();

ExtensionUtility::configurePlugin(
  'MyExtension',
  'ConferenceList',
  [ConferenceController::class => 'list, show, create'],
  // The create action persists a new conference and must not be cached:
  [ConferenceController::class => 'create'],
);

```

Here `list` and `show` are cacheable, while `create` — which
persists a new conference and must not be served from cache — is listed again in
the fourth argument to mark it non-cacheable. An action named in the fourth
argument must also appear in the third; the fourth argument does not *add*
actions, it only flags which of the registered actions are non-cacheable.

> [!NOTE]
> **See also**
>
> [Registering a frontend plugin](https://docs.typo3.org/permalink/t3coreapi:extbase-registration-frontend-plugin-configure@main) — the full
> `configurePlugin()` signature and the other arguments.

## What a non-cacheable Extbase action means: `USER` versus `USER_INT` {#extbase-caching-noncacheable-userint}

An Extbase plugin is rendered on the page as a content object. In its normal,
cacheable state it is a [USER](https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/ContentObjects/UserAndUserInt/Index.html#cobj-user) object: TYPO3 runs it
once, writes the result into the page cache, and later requests for that page
never execute the plugin again.

Every request to the plugin resolves to exactly one controller and one action —
that combination is what the request carries. When the resolved action is
non-cacheable, Extbase converts the plugin into a
[USER_INT](https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/ContentObjects/UserAndUserInt/Index.html#cobj-user-int) object instead. A USER_INT object is
*non-cached* — it is rendered fresh on every single request, after the page
cache has been read, so its output is never stored. This is the same mechanism
TYPO3 uses for any dynamic content element.

The caching state is therefore decided **per action**, not per plugin. A plugin
that registers `list`, `show` and `create` serves `list` and
`show` from the cache, while a request that resolves to `create` is
rendered uncached — the action the request resolves to is what decides. Actions
that a listed action forwards to, or redirects into, are a separate step; it is
always the primary action of the incoming request that determines whether that
render is cached.

> [!NOTE]
> Within a single non-cacheable render, the whole output of that render is
> uncached — `USER_INT` is all or nothing for the request it
> applies to. You cannot keep *part* of a non-cached action's output in the
> page cache through this mechanism. To avoid repeating expensive work inside
> such an action, cache it yourself — see
> [Keeping a non-cached Extbase plugin fast](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-noncacheable-optimise@main).

A redirect or an error response is also never cached, even from an action you
declared cacheable: Extbase avoids caching a plugin whose action redirects, so
that the redirect is re-evaluated on every request. The surrounding page cache
is left untouched in that case.

## The cost of a non-cacheable Extbase action: it runs on every request {#extbase-caching-noncacheable-cost}

A cacheable action pays its cost once. A non-cacheable action pays it on every
request, for every visitor.

Consider a conference list. As a cacheable action, the repository query runs on
the first visit to the page; the resulting HTML is cached and served to everyone
else without touching the database or Extbase again. As a non-cacheable action,
the same query — and the template rendering, and any service calls or API
requests the action makes — runs afresh for every page view. On a busy page that
is the difference between one query and thousands.

> [!WARNING]
> When an action is non-cacheable, TYPO3's page cache no longer protects your
> site from the cost of that action. Every expensive thing the action does —
> database queries, remote API calls, heavy computation — happens on every
> request. Keeping a non-cached action fast is **your** responsibility, not the
> framework's. This is the single most important thing to understand before
> disabling caching.

## When an Extbase action needs to be non-cacheable — and when it does not {#extbase-caching-noncacheable-when}

Reach for a non-cacheable action only when the output genuinely cannot be shared
between requests or visitors:

-   a form that must render a fresh CSRF
    token on every request;
-   output that depends on the currently logged-in frontend user;
-   data that must be live on every view (a live counter, a real-time status).

Most plugin actions are **not** in this category. A list or detail view that
shows the same records to everyone is a natural fit for the cache — even when
those records change from time to time. The problem such views actually have is
not "the output is dynamic" but "the cache must be refreshed when the underlying
records change". That is a cache *invalidation* problem, and disabling the cache
is the wrong tool for it:

-   To refresh cached pages when records change, use
    [cache tags and automatic clearing](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-cachetags@main) — you
    keep the performance of the cache and give up staleness, instead of giving up
    the cache entirely.
-   Only when the output truly differs per request does non-cacheable rendering
    become the right choice — and then the optimization techniques below keep the
    cost down.

## Keeping a non-cached Extbase plugin fast {#extbase-caching-noncacheable-optimise}

A non-cacheable action loses the page cache, but it does not have to redo *all*
of its work on every request. You can cache the expensive parts yourself. TYPO3
ships a [caching framework](https://docs.typo3.org/permalink/t3coreapi:caching@main) for exactly this: you store a computed
value under an identifier and read it back on the next request instead of
recomputing it.

The one decision that shapes everything is, for each piece of output: **does the
cached value need to survive the request?** Output that every visitor shares and
that only changes when a record changes should outlive the request and be reused
across visitors — that calls for a persistent cache of your own, invalidated with
[cache tags](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-cachetags@main). Output that must differ per visitor
and only needs to stay consistent *within* one render should die with the request
— that is what the built-in runtime cache is for. The two examples below apply
this test: the first uses a persistent cache throughout; the second combines both,
a persistent layer for the stable part and a runtime layer for the volatile one.

### Caching rendered HTML in your own persistent cache {#extbase-caching-noncacheable-optimise-persistent}

Consider a conference list plugin with a filter form: a free-text search, a set
of filters fed from the conference's own properties (categories, location, …),
sorting options, and pagination. The form submits by **POST**.

This is the case that forces you off the page cache. TYPO3 builds the page cache
identifier from the page arguments — the page ID, the page type, and the
GET parameters covered by the [cHash](https://docs.typo3.org/permalink/t3coreapi:caching-architecture-identifier@main).
The `POST` body is **not** part of that identifier. Two visitors who POST different
searches to the same URL therefore resolve to the *same* page cache entry: the
second visitor would be served the first visitor's results. A detail view that
selects its record through a GET parameter with a valid cHash does not have this
problem — that value *is* in the identifier — but a POST filter form does. So the
list action must be non-cacheable, and caching its output becomes your job.

Cache it on two layers:

-   **The outer layer** is the whole rendered list. Its identifier is built from
    every option that shapes the result — the search word, every active filter,
    the sorting, and the pagination page. Hashing the whole normalized demand
    object captures all of them at once. The first visitor with a given
    combination waits for the list to render; the next visitor with the same
    combination is served the stored HTML.
-   **The inner layer** caches each conference teaser separately under its own
    `<table>_<uid>` identifier. When the outer list has to be rebuilt, the
    teasers that did not change are reused from this layer instead of being
    rendered again.

Tie the two together with [cache tags](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-cachetags@main): tag each
teaser with its own `<table>_<uid>`, and tag the outer list entry with the
identifier of every teaser it contains. This is what makes invalidation
automatic. Register both caches into the `pages` cache **group**, and
[Extbase's automatic cache clearing](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-autoclearing@main) will
flush them by tag on the same record change that flushes the page cache — a
changed conference drops its teaser entry and every list entry that contained it,
with no manual flush code. The next request rebuilds only what was dropped: the
changed conference is rendered afresh, every other teaser still comes from the
inner cache.

**EXT:my_extension/Classes/Controller/ConferenceController.php**

```php
<?php

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Dto\ConferenceDemand;
use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
  // how long is acceptable for a new record to stay hidden?
  // we use 24 hours here
  private const LIST_LIFETIME = 24 * 60 * 60;

  // stable data, covered by flush-by-tag invalidation
  // we use one year here
  private const TEASER_LIFETIME = 365 * 24 * 60 * 60;

  public function __construct(
    protected readonly ConferenceRepository $conferenceRepository,
    // Two of your own caches, registered in ext_localconf.php in the 'pages'
    // group so automatic cache clearing flushes them by tag on record change.
    #[Autowire(service: 'cache.my_extension_list')]
    protected readonly FrontendInterface $listCache,
    #[Autowire(service: 'cache.my_extension_teaser')]
    protected readonly FrontendInterface $teaserCache,
  ) {}

  public function listAction(ConferenceDemand $demand): ResponseInterface
  {
    // Outer layer: the whole rendered list. The identifier must contain
    // every option that shapes the result — the free-text search word, the
    // sorting, the pagination page AND every filter (categories, location,
    // …) fed from the conference's own properties. Hashing the whole normalised demand object
    // covers them in one step and cannot silently drop a filter you add later.
    $listIdentifier = 'list_' . hash('xxh3', serialize($demand->toArray()));

    $renderedList = $this->listCache->get($listIdentifier);
    if ($renderedList === false) {
      $conferences = $this->conferenceRepository->findByDemand($demand);

      // Inner layer: render each teaser once and cache it under its own
      // <table>_<uid> identifier, so an unchanged conference is reused
      // even when the surrounding list has to be rebuilt.
      $teasers = [];
      $tags = [];
      foreach ($conferences as $conference) {
        $tag = 'tx_myextension_domain_model_conference_' . $conference->getUid();
        $teaser = $this->teaserCache->get($tag);
        if ($teaser === false) {
          $teaser = $this->renderTeaser($conference);
          $this->teaserCache->set($tag, $teaser, [$tag], self::TEASER_LIFETIME);
        }
        $teasers[] = $teaser;
        $tags[] = $tag;
      }

      $renderedList = $this->renderList($teasers);
      // Tag the list with every teaser it contains. When one of those
      // conferences changes, automatic cache clearing flushes this list
      // entry by that tag; the rebuild reuses the teasers that did not change.
      $this->listCache->set($listIdentifier, $renderedList, $tags, self::LIST_LIFETIME);
    }

    return $this->htmlResponse($renderedList);
  }

  private function renderTeaser(Conference $conference): string
  {
    // Render one teaser partial to a string, for example with
    // $this->view->render('Conference/Teaser') after assigning $conference.
    return '…';
  }

  /**
   * @param list<string> $teasers Already-rendered teaser HTML fragments
   */
  private function renderList(array $teasers): string
  {
    // Render the surrounding list markup (form, sorting, pagination) and
    // place the teaser fragments into it.
    return '…';
  }
}

```

Both caches here are ones you register yourself, because their entries must
survive the request and be shared between visitors. Registering a custom cache
is a caching-framework task, not an Extbase one — just make sure to put them in
the `pages` group so record changes clear them along with the page cache.
See [Quick start for integrators](https://docs.typo3.org/permalink/t3coreapi:caching-quickstart@main) and
[Cache registration](https://docs.typo3.org/permalink/t3coreapi:caching-developer-registration@main) for the
`ext_localconf.php` configuration and the service names used above.

### Setting a lifetime for a self-managed Extbase cache {#extbase-caching-noncacheable-optimise-lifetime}

A self-managed cache has three settings that together decide whether it serves
the right content, and the lifetime is as important as the other two. The
**identifier** decides *which* entry a request reads. The **tags** decide *when*
an entry is actively flushed. The **lifetime** decides how long an entry may be
served if nothing flushes it — and for the outer list, something crucial does
*not*.

Tag-based flushing is not symmetric. Tagging the list entry with each teaser it
contains means that when a conference **changes or is removed**, its
`<table>_<uid>` tag flushes every list entry that included it, through
[automatic cache clearing](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-autoclearing@main). But a conference
that is **added** carries no tag that any existing list entry was built with —
the new record was not part of any cached list, so nothing references it and
nothing is flushed. The filtered lists that should now include it keep serving
their old contents until they expire. The same blind spot applies to any change
that bypasses Extbase's write path, and completely to data that lives outside
TYPO3 altogether, so no write event ever fired.
The only saving grace for this scenario is to include the page into the [TCEMAIN.clearCacheCmd](https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/PageTsconfig/TceMain.html#pagetcemain-clearcachecmd)
list, but this comes with the cost, that absolutely each change in the storage folder will invalidate
absolutely all cache entries, which is usually expensive and therefor not wanted.

The lifetime is what closes that gap, which is why it is worth choosing
deliberately. We recommend not relying on the backend's default lifetime: if you
omit the fourth argument of `set()`, the entry inherits whatever the cache
backend is configured with — a value the *integrator* chose for scenarios you may
know nothing about, and possibly unlimited. Passing an explicit lifetime, sized to
how the data actually changes, keeps that decision in your hands:

-   The **outer list** works well with a rather short lifetime — a day in the example,
    less for a busy, fast-moving catalog — because an addition will not flush
    it. That lifetime becomes the ceiling on how long a newly added record can
    stay invisible. Decide by how long stale or invisible data is acceptable and set this
    as the value.
-   Each **teaser** can take a long lifetime — a year in the example — because it
    is flushed by its own tag the moment its conference changes, and if the
    conference is removed it will simply never requested again.
    A stale cache entry does not hurt, so a long lifetime costs nothing here.

The right values are a trade-off between freshness and load, and only you know the
data. The guideline is simply to let each `set()` state its lifetime on
purpose, rather than inherit one by leaving the argument out.

### Mixing a persistent and a runtime cache in one non-cacheable action {#extbase-caching-noncacheable-optimise-runtime}

Now the harder case: a conference detail view that is fully cacheable — except for
one section in the middle of the content, "conferences you might find
interesting", that must be picked fresh for every visitor.

First, the reassuring part: **a detail view with no dynamic parts needs none of
this.** If everything on the page is the same for every visitor, leave the action
cacheable and let the page cache do its job — a GET record id with a valid cHash
already gives each record its own page cache entry. Reach for the techniques below
only when a genuinely per-visitor fragment is mixed into otherwise stable output.

It is tempting to keep the detail action cacheable and render just the strip as a
[USER_INT](https://docs.typo3.org/m/typo3/reference-typoscript/main/en-us/ContentObjects/UserAndUserInt/Index.html#cobj-user-int) island. **This does not work from inside
the plugin's template.** A ViewHelper such as
[f:cObject](https://docs.typo3.org/other/typo3/view-helper-reference/main/en-us/Global/CObject.html#typo3-fluid-cobject) renders its target
inline, at the moment the detail plugin is rendered — which is cache-generation
time. Its output is baked into the plugin's cached HTML, exactly as if it had
never been dynamic. TYPO3 only promotes a *top-level* content object to USER_INT;
it cannot turn a fragment nested inside an already-cached plugin into a live
island, and core ships no ViewHelper that suppresses caching for a wrapped block.
Rendering the strip through a *separate* plugin would work — a separate content
object can be USER_INT — but for the sake of this example the strip sits in the thick of the detail
content, not at its edge, so a second content element the editor places elsewhere
is not a realistic layout. The detail action therefore has to be non-cacheable,
and, as in the list example, caching becomes your job.

The trick is to rebuild, by hand, what the page cache does with its own USER_INT
markers: cache the stable HTML **with a placeholder** where the dynamic strip
belongs, and substitute the freshly rendered strip into that placeholder on every
request. This uses both cache lifetimes at once:

-   **The outer layer** is the whole detail view, including a fixed placeholder
    marker (an HTML comment such as `<!--###RECOMMENDATIONS###-->`) rendered by
    the template where the strip should appear. This HTML is identical for every
    visitor and changes only when the record changes, so it goes in a
    **persistent** cache, tagged with the record's `<table>_<uid>` so a
    change flushes exactly this entry. It is rendered once and reused across
    visitors.
-   **The inner layer** is the recommendations strip. It must differ per visitor
    and per revisit, so it goes in the **runtime** cache: computed at most once
    per request, consistent wherever the render refers to it, and gone when the
    request ends.

The final step is a plain `str_replace()` of the placeholder with the strip.
The substituted result is passed straight to the response and is **never written
back to the cache** — only the placeholder version is stored — so the marker
survives in the cached copy and every request can splice again. String
substitution is all core itself does here.

**EXT:my_extension/Classes/Controller/ConferenceController.php**

```php
<?php

namespace MyVendor\MyExtension\Controller;

use MyVendor\MyExtension\Domain\Model\Conference;
use MyVendor\MyExtension\Domain\Repository\ConferenceRepository;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;

class ConferenceController extends ActionController
{
  public function __construct(
    protected readonly ConferenceRepository $conferenceRepository,
    // Your own persistent cache, registered in ext_localconf.php in the
    // 'pages' group so automatic cache clearing flushes it by tag on change.
    #[Autowire(service: 'cache.my_extension_detail')]
    protected readonly FrontendInterface $detailCache,
    // The runtime cache is registered by TYPO3 out of the box. It lives for
    // the duration of one request only and is never written to storage.
    #[Autowire(service: 'cache.runtime')]
    protected readonly FrontendInterface $runtimeCache,
  ) {}

  // A fixed marker the detail template renders where the strip belongs.
  private const RECOMMENDATIONS_PLACEHOLDER = '<!--###RECOMMENDATIONS###-->';

  // stable data, covered by flush-by-tag invalidation
  // we use one year here
  private const DETAIL_LIFETIME = 365 * 24 * 60 * 60;

  // The whole show action is non-cacheable, because the recommendations strip
  // sits in the middle of the detail view and cannot be split off into its own
  // content object. We rebuild the page cache's own trick by hand: cache the
  // stable HTML with a placeholder, then substitute the dynamic strip into it.
  public function showAction(Conference $conference): ResponseInterface
  {
    // Outer layer: the whole detail view, INCLUDING the placeholder marker.
    // It is identical for every visitor of this record and only changes when
    // the record does, so it belongs in a PERSISTENT cache. Tag it with the
    // record so a change flushes exactly this entry.
    $tag = 'tx_myextension_domain_model_conference_' . $conference->getUid();
    $html = $this->detailCache->get($tag);
    if ($html === false) {
      // Render the detail template once. It contains the literal marker
      // RECOMMENDATIONS_PLACEHOLDER at the spot the strip should appear.
      $html = $this->renderDetail($conference);
      $this->detailCache->set($tag, $html, [$tag], self::DETAIL_LIFETIME);
    }

    // Inner layer: the per-visitor recommendations strip. Because it must
    // differ per visitor and per revisit, it belongs in the RUNTIME cache.
    // The fixed key keeps it stable within THIS render.
    $strip = $this->runtimeCache->get('recommendations');
    if ($strip === false) {
      $conferences = $this->conferenceRepository->findRecommendations();
      $strip = $this->renderRecommendations($conferences);
      $this->runtimeCache->set('recommendations', $strip);
    }

    // Splice the freshly picked strip into the cached HTML — a plain string
    // replacement, exactly what TYPO3 does with its own USER_INT markers.
    // The result is returned directly and never cached, so the placeholder
    // stays in the cached copy and the next request can splice again.
    $html = str_replace(self::RECOMMENDATIONS_PLACEHOLDER, $strip, $html);

    return $this->htmlResponse($html);
  }

  private function renderDetail(Conference $conference): string
  {
    // Render the detail template to a string, for example with
    // $this->view->render('Conference/Show') after assigning $conference.
    // The template must contain RECOMMENDATIONS_PLACEHOLDER where the strip
    // should appear.
    return '…';
  }

  /**
   * @param iterable<Conference> $conferences The picked recommendations
   */
  private function renderRecommendations(iterable $conferences): string
  {
    // Render the recommendations partial to a string.
    return '…';
  }
}

```

The runtime cache is registered by TYPO3 out of the box; you inject it by its
service name and use it immediately. The persistent detail cache is one you
register yourself, exactly as in the list example.

> [!NOTE]
> **See also**
>
> -   [Caching framework: register your own cache](https://docs.typo3.org/permalink/t3coreapi:caching-quickstart@main)
>     — how to add a persistent cache in `ext_localconf.php`.
> -   [Extbase cache tags and automatic clearing](https://docs.typo3.org/permalink/t3coreapi:extbase-caching-cachetags@main)
>     — tagging cache entries and flushing them when records change.
