Hasura – Caching and Performance

August 2, 20264 min readUpdated 8/21/2026

Hasura generates good SQL. That does not make your API fast, because the query it generates still has to run against your database, and the shapes GraphQL makes easy are not always the shapes Postgres finds cheap.

Look at the SQL first

Before tuning anything, see what is actually running:

curl -s -X POST http://localhost:8081/v1/graphql/explain \
  -H 'x-hasura-admin-secret: stayhub-admin-secret' \
  -d '{"query":{"query":"query { properties(limit:2){ title host { firstName } } }"}}'

You get the generated SQL and the plan Postgres chose:

Aggregate  (cost=5.19..5.20 rows=1 width=32)
  ->  Nested Loop Left Join  (cost=0.01..5.16 rows=2 width=63)
        ->  Limit  (cost=0.00..0.45 rows=2 width=1766)
              ->  Seq Scan on properties  (cost=0.00..3.14 rows=14 width=1766)
        ->  Memoize  (cost=0.01..3.11 rows=1 width=32)
              Cache Key: properties.host_id

A sequential scan on fourteen rows is correct — an index would be slower. The same plan on fourteen million rows is the whole problem. Always explain against realistic data volumes; a development database tells you almost nothing about which plans will hold.

Indexes are where the wins are

Hasura exposes every column to where and orderBy, which means clients can filter on columns you never indexed. The fix is ordinary database work:

CREATE INDEX idx_properties_city_status ON properties (city, status);
CREATE INDEX idx_bookings_property_dates ON bookings (property_id, check_in, check_out);
CREATE INDEX idx_properties_title_trgm ON properties USING gin (title gin_trgm_ops);

That last one matters more than people expect. _ilike: "%villa%" is a leading wildcard, and no B-tree index can serve it — it is a full scan every time. A trigram index can. If you have exposed _ilike on a large table and not added one, that is likely your slowest query.

Foreign keys are worth checking too: Postgres indexes the primary key side automatically but not the referencing column, so bookings.property_id needs an explicit index for the nested lookups in lesson 5 to stay cheap.

Depth costs more than it looks

Nesting multiplies. Twenty listings, each with their bookings, each with a guest is not twenty-one queries — lesson 5 showed it is one — but it is one query assembling a lot of JSON. Add pagination at every level, not just the top:

query {
  properties(limit: 20) {
    title
    bookings(limit: 5, orderBy: {checkIn: DESC}) {
      checkIn
      guest { firstName }
    }
  }
}

Limits are the real protection

The single most effective thing you can do is a row limit on every public role’s select permission:

perm["limit"] = 100

Without it, query { properties } with no arguments returns the entire table. That is how a GraphQL endpoint becomes a denial-of-service vector against its own database, and it needs no malice — one developer testing in the console will do it.

Hasura also supports depth and node limits, and rate limiting per role. Those are Enterprise and Cloud features — see the note below. The permission row limit is not, and it is available to everyone.

RESTified endpoints

Not every client wants GraphQL, and every GraphQL request is a POST with a body — which no HTTP cache or CDN will cache. Hasura can pin a saved query to a URL:

GET /api/rest/properties/seattle

You get a normal REST endpoint backed by a fixed, reviewed query. Two real benefits: the response is cacheable by ordinary infrastructure, and the query text is fixed, so clients cannot ask for something more expensive than you intended. Good for public, high-traffic, read-only endpoints.

Response caching

Community Edition. The engine this track runs against reports server_type: ce. The features named below as Enterprise or Cloud were not demonstrated here — they are described from the documentation and labelled, rather than shown running.

Hasura can cache query responses with a directive:

query PopularListings @cached(ttl: 120) {
  properties(where: {status: {_eq: "PUBLISHED"}}, orderBy: {ratingAverage: DESC}, limit: 20) {
    title
    city
    ratingAverage
  }
}

The cache key includes the session variables, so two roles never share an entry — which is the correctness property that makes it safe with row-level permissions.

@cached, allow-lists, rate limiting and read-replica routing are Enterprise and Cloud. On Community Edition your caching options are in front of Hasura: a CDN over RESTified endpoints, or a cache in your own client layer.

Connection pooling

The failure that arrives first in production, and it is not a query problem. Every Hasura replica holds its own pool. Three replicas at fifty connections each is a hundred and fifty against a Postgres that probably allows a hundred.

HASURA_GRAPHQL_PG_CONNECTIONS: "50"

Size it as replicas × connections < max_connections, leaving room for your application and for migrations. Under real pressure, put PgBouncer in front — and then keep Hasura’s own pool small, because two pools stacked on each other is worse than either.

Performance in v3 (DDN)

On the v3 sections. Everything marked v3 (DDN) is taken from the official Hasura DDN documentation as read on 2026-08-21 and was not run locally — it shows configuration, never claimed output. The v2 material was executed against a running engine.

The architectural difference is push-down. A v3 connector declares which operations it can perform natively, and the engine delegates as much as possible to the data source — including authorization, which v2 could not push down. Permission filters become part of the source’s own query rather than something applied on top.

Caching in v3 is delivered via plugins rather than a built-in directive, and API limits are marked work in progress. One thing moves in your favour: read replicas are no longer an Enterprise feature in DDN.

Next

Lesson 15 covers what to turn off before this faces the internet.