NAME

Catalyst::Seal - freeze a Catalyst application at setup and make it 4x faster

VERSION

Version 0.01

seal

Catalyst::Seal->seal('MyApp');

Runs every registered step against the application class. Called for you from setup_finalize; call it directly only if you are sealing an application that did not use Catalyst::Seal.

Each step runs in its own eval. A step that dies is a bug in this distribution, so it always warns rather than disappearing into the hook, and the remaining steps still run.

SYNOPSIS

package MyApp;

use Catalyst::Seal;
use Catalyst qw/ ConfigLoader Static::Simple /;

__PACKAGE__->setup();

DESCRIPTION

After setup_finalize a Catalyst application is frozen: the class tree, the configuration, the action table and the attribute layouts all stop changing. Catalyst goes on re-deriving those facts from the metaobject protocol on every request. Catalyst::Seal makes one pass at the end of setup() and compiles the frozen facts into constant form.

No application code changes. Place the use line above use Catalyst and nothing else.

What this module does

  • Makes the application class and its components immutable, which Catalyst does for controllers but not for the application class, even though the application class is also the per-request context class.

  • Replaces the two Try::Tiny blocks on the request path with plain eval.

  • Replaces every mk_classdata accessor with an XS constant. The stock one calls Moose::Util::find_meta on every read to answer a question that stopped changing at setup_finalize: 84 of those calls per request on a bare application. A write unseals the accessor and puts the original back.

  • Takes find_meta out of the Catalyst::Component::config read path.

  • Installs the composed body of every method carrying a before, after or around modifier directly, in place of the trampoline Class::MOP::Method::Wrapped installs, which calls set_subname on every invocation to attach a name that has not changed since the modifier was applied. Ten of those are on the request path. A modifier added after the seal puts the trampoline back.

  • Short-circuits the Catalyst::Response guard that warns about setting a header after the headers were finalised, which on a read evaluates three accessors to reach a condition that already could not be true.

  • Seals $c->config as a constant. Catalyst croaks on any write to it once setup has finished, so after setup it cannot change, and the whole around, find_meta, get_or_add_package_symbol chain behind it is dead weight on every one of the nine reads a request makes.

  • Replaces the attribute readers on the context, request and response classes with XS. Only readers, only on immutable classes, and only where the reader is Moose's own. A lazy attribute whose slot is not built yet delegates to that reader, so the builders stay Moose's and a predicate keeps telling the truth.

  • Aliases req, res and comp straight to the methods they delegate to, removing a whole frame from each of 25 calls a request.

  • Memoises the two action lookups the forward chain repeats on every request. One action costs five forwards, each of which turns a string like '/foo/_BEGIN' into an action object by walking a table that stopped changing at setup_finalize. The chain itself is not flattened: the private steps go on $c->stack, and $c->depth is what gates the detach and go rethrows, so a flat chain would have to reimplement execute to keep them honest.

  • Memoises the encoding decision. finalize_encoding spends 49 us per request deciding that a text/plain body does not need encoding, by calling content_type four times and content_type_charset three times to re-parse one header string. The answer is a pure function of the raw content type, the content encoding, the encodable-type pattern and the application encoding.

  • Replaces the Try::Tiny in Moose's inlined destructor for Catalyst::Response with an eval. Two closures were being built on every response destroyed, to call a four line DEMOLISH.

  • Replaces the Try::Tiny in _handle_param_unicode_decoding with an eval. Every query parameter, body parameter and path argument is decoded through it, name and value separately, so a four parameter query string builds sixteen closures to decode eight strings. Worth 15 us on such a request, which is more than the parsing it surrounds.

  • Builds the HTTP::Headers hash in prepare_headers directly rather than through one header call per environment key. The spelling of each field is asked of HTTP::Headers once, the first time a request carries it, and remembered, so nothing here has to know which headers HTTP::Headers considers standard.

  • Skips URI::canonical in prepare_path when the URI just built is already canonical, which is one regex to decide and three authority parses to ask. Checked against URI itself at seal time, positive and negative, rather than against its source.

  • Stops a controller's BUILD firing two lazy builders on the per-request context object. The application class inherits Catalyst::Controller, so BUILDALL runs its BUILD on every context object, to compute values derived entirely from class data.

  • Defers building the Catalyst::Stats object when stats are disabled. Its tree attribute is required and not lazy, so stock builds a Tree::Simple and reads the clock on every request for an object nothing then reads. With stats enabled nothing changes, because that timestamp is the request start.

On a bare application, 267.4 to 78.0 us per request, and on Hyperman under wrk 3,740 to 20,340 requests per second.

cat-hello              6586 req/s
cat-seal              20461 req/s

Environment

CATALYST_SEAL=0

Hard kill switch. Nothing is sealed and the application is stock Catalyst.

CATALYST_SEAL_DEBUG=1

Report what was sealed, and what was skipped and why.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)