Make WordPress Core

Opened 9 months ago

Last modified 7 weeks ago

#63946 new enhancement

Introduce a mechanism for lazy-loading REST routes and handlers

Reported by: prettyboymp's profile prettyboymp Owned by:
Milestone: Awaiting Review Priority: normal
Severity: normal Version:
Component: REST API Keywords: has-patch has-unit-tests
Focuses: performance Cc:

Description

Registering REST API routes through controllers introduces additional overhead on every REST request. Benchmarking shows that the default routes registered during create_initial_rest_routes() take approximately 8.5–10ms in local testing. While this cost is relatively small in isolation, it is incurred on every request regardless of the endpoint being accessed. The impact grows even more as plugins register their own endpoints on top of core routes.

The main performance cost is not from register_rest_route() itself, but from preparing the arguments passed into it. Approximately 20% of this overhead is attributed to translation handling.

One possible solution would be to introduce a function such as register_rest_namespace(). This would allow a namespace to be declared during rest_api_init without requiring route arguments to be constructed immediately. At request time, during rest_pre_dispatch, a namespaced hook could be fired for the relevant namespace(s), enabling just-in-time registration of only the routes required for that request.

For API discovery requests, all namespaces would still need to be fully registered to ensure complete endpoint visibility. For most other requests, however, this approach could significantly reduce the cost of processing for all other requests by avoiding argument construction for unused routes.

Change History (16)

#1 @rmccue
9 months ago

Related: #41305

#2 @prettyboymp
9 months ago

After some further digging, I noticed that the WP_REST_Server::get_target_hints_for_link(), which attempts to match internal links to handlers also degrades quite a bit as new route handlers are added.

The default /wp-json/wp/v2/posts endpoint ends up calling running this for every post returned in the collection.

This ticket was mentioned in PR #10080 on WordPress/wordpress-develop by prettyboymp.


9 months ago
#3

  • Keywords has-patch has-unit-tests added

Adds support for registering namespaces to the REST API Server for the purposes of lazy loading. The server will trigger a action specific to that namespace when corresponding routes would need to be loaded. This provides a performance improvement when adding new namespaces and routes by reducing the number of routes that need to be checked for matches and avoiding unnecessary translation processing.

Trac ticket: https://core.trac.wordpress.org/ticket/63946

This ticket was mentioned in Slack in #core-restapi by prettyboymp. View the logs.


6 months ago

@kraftbj commented on PR #10080:


4 months ago
#5

I rebased against trunk and performed a Claude review. There may be some issues that need to be addressed so sharing them here before looking deeper and/or asking for more eyes.

---

## Review Notes

### Positives

  • Clear performance win for sites with many REST namespaces. Avoids loading/translating routes that are never hit.
  • Good test coverage — 12+ new test cases covering registration, dispatch, idempotent loading, mixed lazy/regular, double registration, namespace index, root index, and "no unnecessary loading."
  • Non-breaking — existing register_rest_route() continues to work identically. Lazy loading is opt-in.
  • Clean dispatch integration — match_request_to_handler() checks both $this->namespaces and $this->lazy_namespaces when matching, and only loads the specific namespace needed.

### Issues & Concerns

1. get_index() doesn't include lazy namespaces in the namespaces array (Possible Bug)

After calling $this->load_all_lazy_namespaces(), the index still only reports:

'namespaces' => array_keys( $this->namespaces ),

If a lazy-loaded namespace's action registers routes via register_rest_route(), those routes will populate $this->namespaces as a side effect. But if the lazy load action fails or registers nothing, the namespace won't appear in the index. This should probably be:

'namespaces' => array_keys( $this->namespaces + $this->lazy_namespaces ),

2. get_namespaces() doesn't include lazy namespaces

get_namespaces() still returns only array_keys( $this->namespaces ). Callers outside the class who use this public method won't see lazy-registered namespaces, even after they've been loaded. The test test_lazy_namespace_registration explicitly asserts this as expected behavior, but it seems like a design gap — once a lazy namespace has been loaded, shouldn't it appear in get_namespaces()? There's a todo comment in the test acknowledging this open question.

3. Namespace matching in match_request_to_handler() has a prefix collision issue

if ( str_starts_with( trailingslashit( ltrim( $path, '/' ) ), $namespace ) ) {

This checks if the path starts with the namespace string, but without a trailing slash on $namespace. So a request to /test/v1-extended/foo would match namespace test/v1 because "test/v1-extended/foo/" starts with "test/v1". This could cause unnecessary lazy-loading of the wrong namespace. The fix would be:

if ( str_starts_with( trailingslashit( ltrim( $path, '/' ) ), trailingslashit( $namespace ) ) ) {

(Note: this bug likely pre-exists in trunk's match_request_to_handler, but lazy loading makes it more impactful since it triggers side effects.)

4. Validation order in register_rest_namespace() is suboptimal

The empty() check runs before the is_string() check. A non-string value like an array would hit the empty() branch first and try to use it in a sprintf, which could produce warnings. The is_string() check should come first.

5. Typo in comment

"namespases" should be "namespaces" (line 1276 area).

6. Missing @since tags on several new methods

register_lazy_loaded_namespace(), load_lazy_namespace(), and load_all_lazy_namespaces() are all missing @since tags in their docblocks, while other new additions (constants, properties) have @since X.X.0.

7. rest_lazy_load_namespace generic action docblock is sparse

The generic rest_lazy_load_namespace action has a minimal docblock compared to the namespace-specific one. It should document the $route_namespace parameter that's passed.

8. No test for register_rest_namespace() validation paths

The global register_rest_namespace() function has validation for empty namespace, non-string namespace, leading/trailing slashes, and calling before rest_api_init. None of these validation branches are tested.

9. Route priority tests seem unrelated to lazy loading

test_route_priority_registration_order and test_route_priority_reverse_registration_order test existing WP_REST_Server behavior (route matching order), not lazy loading. They're tagged with ticket 63946 but don't use lazy namespaces. They may be here to document assumptions the lazy loading depends on, but that should be clarified in the test docblocks.

@prettyboymp commented on PR #10080:


3 months ago
#6

@kraftbj, thanks for the reviewing. I addressed several items in 9233b0f422.

1. get_index() doesn't include lazy namespaces in the namespaces array - By design. The contract of register_rest_namespace() is that routes will be registered when the lazy load hook fires. After load_all_lazy_namespaces(), any correctly implemented lazy namespace will have called register_rest_route(), which populates $this->namespaces. A namespace that registers nothing is a misconfiguration and correctly omitted from the index.

2. get_namespaces() doesn't include lazy namespaces - Intentional for backward compatibility. get_namespaces() returns namespaces that have registered routes. Including unloaded lazy namespaces would either require triggering all lazy loads as a side effect (defeating the purpose) or returning namespaces with no routes (misleading to callers). Any existing namespace migrating to lazy loading takes on this trade-off explicitly. Resolved the @todo in the test to clarify this is expected behavior.

3. Namespace prefix collision — Fixed. Added trailingslashit( $namespace ) to the str_starts_with check so test/v1 no longer falsely matches test/v1-extended/... paths. This is safe - actual route matching is still done by regex, so no backward compatibility concern.

4. Validation order - Fixed. is_string() check now runs before empty().

5. Typo — Fixed.

6. Missing @since tags - Fixed. Added @since X.X.0 to all three new methods.

7. Generic action docblock - Fixed. Added full description and @param documentation.

8. Validation path tests - Skipping for now, but happy to add if we think its helpful. There's no existing precedent for testing _doing_it_wrong validation on global REST API functions in the test suite.

9. Route priority test docblocks - Fixed. Added clarifying text that these document existing route priority behavior that the lazy loading changes to match_request_to_handler() must preserve.

#7 @rmccue
3 months ago

This feels to me a bit like the wrong approach: registering the namespaces and their routes isn't expensive (it's basically just adding to an array), rather it's the argument building that's slow.

I'd suggest a better API here would be to make the $args parameter of type array|callable, and lazily call this when the route's arguments are needed. This would keep namespaces and routes registered, while still offloading the actual majority of the slow operations.

It would also be easy to transition, especially with arrow functions; you'd change your code from

register_rest_route( 'myexample/v1', '/ping', [
	'methods'  => 'GET',
	'callback' => [ $this, 'handle_ping' ],
	'args' => $this->get_endpoint_args_for_item_schema(),
	'schema' => $this->get_item_schema(),
] );

to

register_rest_route( 'myexample/v1', '/ping', fn () => [
	'methods'  => 'GET',
	'callback' => [ $this, 'handle_ping' ],
	'args' => $this->get_endpoint_args_for_item_schema(),
	'schema' => $this->get_item_schema(),
] );

and magically your performance would improve.

This conceptually moves the route registration then from being potentially a heavy operation to effectively

$this->routes[ $namespace ][ $route ] = $args;

which is heavily optimised in PHP (3 opcodes).

This improves the performance of non-REST API entrypoints (although proper usage of rest_api_init avoids this anyway), and the performance of individual routes themselves, as the args only need full evaluation after URL matching.

#8 follow-up: @prettyboymp
3 months ago

@rmccue I actually explored the callable $args approach initially before landing on namespace-level lazy loading, but I hit some backward compatibility issues.

  1. rest_endpoints filter: get_routes() passes $this->endpoints through this filter (line 968). Plugins iterating the filtered data expect arrays, not Closures — storing Closures in $this->endpoints would break them.
  1. rest_send_allow_header(): Calls $server->get_routes() without a namespace parameter on every REST response (line 880), which would force resolution of all Closures, not just the matched route's. This also runs per-item via get_target_hints_for_link() in collection responses.

On the point that "registering the namespaces and their routes isn't expensive, rather it's the argument building that's slow" — the registration itself is cheap, but having more entries in $this->endpoints has a per-request cost beyond route matching. rest_send_allow_header() calls get_routes() without a namespace parameter (line 880), so on every REST response the full endpoints array is copied, passed through the rest_endpoints filter, and normalized (lines 979-1016). This also runs per-item via get_target_hints_for_link() in collection responses. Every registered route — even ones whose namespace doesn't match the current request — adds to that cost.

I do like the callable approach as a cleaner implementation — I just don't know if we can get away with breaking the expectation of what's passed through the rest_endpoints filter. The namespace approach sidesteps that entirely because lazy namespaces never enter $this->endpoints until fully loaded.

#9 in reply to: ↑ 8 ; follow-up: @rmccue
8 weeks ago

Replying to prettyboymp:

  1. rest_endpoints filter: get_routes() passes $this->endpoints through this filter (line 968). Plugins iterating the filtered data expect arrays, not Closures — storing Closures in $this->endpoints would break them.

Any API change here is going to break something, but I take the point there. We could handle the with a back-compat wrapper class for any callable which implements ArrayAccess - it's a little unclean on the implementation (core) side in that sense, but should work.

Here's an example implementation, tested against code from Really Simple Security. The Resolvable class here implements just-in-time resolution for any callable, so actually we likely wouldn't need to change the Server code itself either. I'll see about spinning up a PR for this.

Note that I didn't bother adding the single-to-multiple array normalisation here for now - we'd need to make a decision as to whether the callback can provide multiple endpoints or not. I'm leaning no, because it makes it impossible to merge together later, which is necessary for registering GET/POST as separate calls.

(Aside: This form of resolution has a secondary benefit, which is that it's more conducive to FastRoute-style optimisations (eg "put all the routes in a big regex and offload the loop to PCRE"). Typical sites are getting to the point that they have enough routes to make this approach one worth considering now; ideally, having the method as well as the URL would allow off-the-shelf usage of FastRoute, but that's a very minor optimisation/design choice.)

  1. rest_send_allow_header(): Calls $server->get_routes() without a namespace parameter on every REST response (line 880), which would force resolution of all Closures, not just the matched route's. This also runs per-item via get_target_hints_for_link() in collection responses.

This should be fixed with the resolvable concept too. The only time a route's definition is "resolved" is when it's used, so we only need to resolve the matched route here.

On the point that "registering the namespaces and their routes isn't expensive, rather it's the argument building that's slow" — the registration itself is cheap, but having more entries in $this->endpoints has a per-request cost beyond route matching. rest_send_allow_header() calls get_routes() without a namespace parameter (line 880), so on every REST response the full endpoints array is copied, passed through the rest_endpoints filter, and normalized (lines 979-1016). This also runs per-item via get_target_hints_for_link() in collection responses. Every registered route — even ones whose namespace doesn't match the current request — adds to that cost.

That's a downstream issue, imo, whereas I'm talking mostly about the broad question of "what currently makes the REST API slower (and/or more memory hungry) than it should be?" The answer to that is basically a) slow string operations of all sorts (translation, etc etc), and b) schema resolution (eg to build args from a schema). Neither of those actually need doing for any route you're not actively using, hence my reasoning behind offloading just the endpoint options.

This ticket was mentioned in PR #11606 on WordPress/wordpress-develop by @rmccue.


8 weeks ago
#10

Adds the ability to register just-in-time resolvable routes for the REST API.

When calling register_rest_route(), you can now pass a function instead of the options for the route directly:

// Before:
register_rest_route( 'example/v1', '/ping', [
        'callback' => 'ping_callback',
        'permissions_callback' => '__return_true',
        'args' => [
                'foo' => [
                        'required' => true,
                        'type' => 'string',
                ],
        ],
        'schema' => $this->get_schema(),
] );

// After:
register_rest_route( 'example/v1', '/ping', fn () => [
        'callback' => 'ping_callback',
        'permissions_callback' => '__return_true',
        'args' => [
                'foo' => [
                        'required' => true,
                        'type' => 'string',
                ],
        ],
        'schema' => $this->get_schema(),
] );

This has the benefit that any more expensive operations (translations, args-to-schema building, etc) are only run for matched routes, rather than all of them. But, routing is still possible without this (including listing all available namespaces).

Trac ticket: https://core.trac.wordpress.org/ticket/63946

See also https://github.com/WordPress/wordpress-develop/pull/10080

#11 in reply to: ↑ 9 @rmccue
8 weeks ago

Replying to rmccue:

Here's an example implementation, tested against code from Really Simple Security. The Resolvable class here implements just-in-time resolution for any callable, so actually we likely wouldn't need to change the Server code itself either. I'll see about spinning up a PR for this.

https://github.com/WordPress/wordpress-develop/pull/11606 implements this, along with some minimal unit tests for the behaviour. I haven't done any significant compatibility testing with the ecosystem, only some minimal tests for the array behaviour.

I've not benchmarked this as I'm partially out-of-office currently; there shouldn't be a huge overhead to instantiating these objects, but given that the overhead of the endpoint registration isn't super high, I'm not sure where it nets out. Finger in the air, I'd guess it's probably slightly worse at small scale (~100 endpoints).

There's one minor compatibility issue this introduces. If you pass an endpoint's options to wp_parse_args(), this internally calls get_object_vars() if the variable is an object, so will break - I had to resolve this by using iterator_to_array(). That's a pretty minor break, as you mostly only need that type of merging when applying defaults, but something we can document; I haven't done an extensive search yet to see if that's in broad use, but I can't imagine so. (It's safe to call iterator_to_array() on arrays, so this can be backported as well. Edit: only from 8.2 alas.)

Last edited 8 weeks ago by rmccue (previous) (diff)

@rmccue commented on PR #11606:


8 weeks ago
#12

(cc @prettyboymp @kraftbj for feedback too, as an alternative to https://github.com/WordPress/wordpress-develop/pull/10080)

@swissspidy commented on PR #11606:


8 weeks ago
#13

Love the ergonomics of this approach 👍

@kraftbj commented on PR #11606:


8 weeks ago
#14

On first glance, these two approaches seem complementary rather than competing, as they tackle 63946 at different layers of the stack.

#10080 defers at the namespace boundary: register_rest_route() never runs for a namespace that isn't matched, which also sidesteps whatever REST-only setup plugins put alongside registration — controller instantiation, register_rest_field, schema construction, boot-time translations, etc.

This one defers at the options boundary: register_rest_route() still runs for every route, but the options array (schema building, args-to-schema, translated strings inside the definition) is only materialized when that specific route is dispatched. Finer-grained & great for routes with heavy schemas, but inside the register call, not around it.

The two stack nicely. We could register a lazy namespace via #10080, and in the action that loads it, use resolvable options for any routes with expensive schemas that might still not be the one hit. Skip the namespace entirely, then if the namespace is hit, skip option construction for non-dispatched routes, and then only fully resolve the actually matched route.

tl;dr: :why-not-both.gif:

@rmccue commented on PR #11606:


7 weeks ago
#15

tl;dr: :why-not-both.gif:

The big factor here is the complexity, both conceptually and from an implementation standpoint.

The benefits from switching registration approach come from all users adopting the approach, so that we can offload as much work to just-in-time as possible. That naturally means that any new approach is going to want to be evangelised as "the" way to do it, so we should carefully consider the ergonomics.

Lazy namespaces are conceptually more complex: they require stacking multiple actions on top of each other (including a dynamic action name) which run at different times. It moves the mental model from "when the API starts, register your endpoints" to "when the API starts, register your namespace. when the namespace is being used, register your endpoints". Don't get me wrong, it's not like it's the end of the world, but if we can avoid that friction through careful design we should.

From an implementation perspective, it's also more complex - both in core, and in plugins working with it. It introduces that new "tier" above routes as an object we have to deal with, and in some ways it also moves the current $with_namespaces code from being an optimisation to being a hard part of the design. (I'd be in favour of seeing if we can actually eliminate that optimisation in favour of an approach like FastRoute.) Every new concept and system we add also has maintenance costs, so if we can avoid adding things, we should consider it.

The core thesis here really is that adding items into an array (and calling the callback that does that) isn't actually expensive, the expensive part of registering routes is building the options. If we offload that and it "solves" the performance concern, why add the complexity of lazy namespaces? That thesis is as-yet untested; to put it through its paces, I'd want to grab a selection of plugins and adapt them and do a before/after on the timing.

I take your point around controller instantiation and similar operations that happen at the registration stage, but _are_ those actually that expensive?

(There's of course also nothing here that would block us from doing both in the future if we wanted to at that stage.)

@kraftbj commented on PR #11606:


7 weeks ago
#16

That's all fair. I have no objection to your approach, with the door being open to something more akin to what @prettyboymp proposed later, if we can articulate the case.

Note: See TracTickets for help on using tickets.