NAME

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

VERSION

Version 0.04

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.

  • Parses the query string in C. The stock parser builds a decoder closure, a Hash::MultiValue and a regex before it has looked at the query, then runs the name and the value of every pair through unescape_uri and _handle_param_unicode_decoding separately. Four pairs cost 12.9 us that way and 1.3 us this way; an empty query string, which cannot cost anything, cost 1.9. The UTF-8 the C decoder accepts is exactly the UTF-8 Encode accepts, noncharacters and all, and anything it is not certain of goes back to Encode to be refused in Encode's own words.

  • Places the headers whose spelling is already known in C, one pass over the environment. 1.5 us to 0.9, on top of the 4.2 the stock body costs.

  • Replaces the constructors for the context, request and response classes with XS built from a template resolved at seal time: which key each attribute reads, whether it has a default and what kind, and which of them Moose would do more than a store for. 8.0 us per request to 4.3. A value for a typed attribute is put to the constraint's own check, and anything that fails it - or a trigger, a coercion, a required attribute with nothing to fall back on, a subclass, an odd argument list - takes the stock constructor entire.

  • Hoists the accessor traffic out of Catalyst::execute, which spends nine accessor calls before it does anything and is called nine times a request, and puts Catalyst::Action::execute and ::dispatch in XS. Both of those exist only to reach a slot: five accessor frames between them, nine times each.

  • Answers a write through a sealed accessor in XS as well as a read, where the attribute is a plain one: 132 ns to 51.

  • Seals use_stats, which setup_stats decided and execute asks twenty times a request, and puts depth - scalar @{ shift->stack } - in XS.

  • 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, 279.1 to 70.6 us per request - measured as the best of five runs of four thousand requests, sealed and unsealed, in the same process as one another.

The throughput figures below are 0.03's and have not been re-run since:

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)