Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

API Caching with ETag and Last-Modified

By Kokil Thapa | Last reviewed: September 2026

API Caching with ETag and Last-Modified is how you tell clients and proxies whether a response body changed since their last fetch. Without it, every GET hits your database and serialiser even when nothing moved. That wastes CPU on API development projects where read-heavy endpoints dominate traffic. HTTP conditional requests solve this at the protocol layer. Your server sends validators in response headers. The client sends them back on the next request. If the resource is unchanged, you reply with 304 Not Modified and skip the body.

What Is API Caching with ETag and Last-Modified?

Validator-based caching is not the same as storing JSON in Redis. Redis caches computed responses inside your app. ETag and Last-Modified work between the client and your origin through standard HTTP semantics defined in RFC 7232.

An ETag is an opaque token representing a specific version of a resource. It might be a hash of the serialised body, a database row version, or a monotonic revision counter. Last-Modified is a timestamp showing when the resource last changed. Both serve the same goal: let the client ask, “Is my copy still valid?”

On a production Laravel application I maintain, product catalogue endpoints use ETags derived from updated_at plus row count. Mobile clients cache responses locally. Repeat opens send conditional headers instead of pulling 200 KB of JSON again. Server load drops because the controller short-circuits before heavy Eloquent work.

Conditional GET Request FlowAPI Clientstores ETagCDN / Proxyoptional layerOrigin APILaravel 13GET + If-None-Match304 Not Modifiedno response body200 OK + ETagfull JSON payloadchangedunchangedUses local cache
API Caching with ETag and Last-Modified: clients revalidate with If-None-Match and receive either 304 or a fresh 200 response.

This pattern pairs well with application-level caching covered in Redis caching patterns for web apps. Use Redis for expensive aggregation queries. Use ETags for client-side and CDN revalidation. They complement each other rather than compete.

How Do If-None-Match and If-Modified-Since Work?

Conditional requests rely on request headers the client sends on subsequent GETs. The server compares them against the current resource state and picks a status code.

Response headers your API must emit

  • ETag: "abc123" — opaque validator, often quoted
  • Last-Modified: Wed, 08 Sep 2026 10:00:00 GMT — HTTP-date in GMT
  • Cache-Control: private, max-age=0, must-revalidate — tells intermediaries to revalidate

Request headers the client sends back

  • If-None-Match: "abc123" — paired with ETag validation
  • If-Modified-Since: Wed, 08 Sep 2026 10:00:00 GMT — paired with Last-Modified

When validators match, respond with 304 Not Modified. Do not include a body. The client keeps its cached copy. When they differ, respond with 200 OK, the full payload, and fresh validators. This is documented clearly on MDN for the ETag header.

A common mistake is returning 304 with a body. Clients ignore it. Another mistake is omitting validators on paginated list endpoints where each page changes independently.

Example raw HTTP exchange

GET /api/v1/products/42 HTTP/1.1
Host: api.example.com
Accept: application/json
If-None-Match: "prod-42-v7"

HTTP/1.1 304 Not Modified
ETag: "prod-42-v7"
Cache-Control: private, must-revalidate

The round trip still hits your server unless a CDN serves the 304 from edge memory. Even then, you skip serialisation and database reads. That is where the savings appear on read-heavy APIs.

How Do You Implement ETag Caching in Laravel 13?

Laravel 13 on PHP 8.3 or higher gives you middleware and response helpers for conditional requests. The cleanest approach computes a stable ETag once per resource and compares it in middleware or the controller.

Step 1: Generate a deterministic ETag

Hash the serialised output plus a version token. For Eloquent models, combine primary key, updated_at, and a content hash if large text fields exist.

<?php

namespace App\Http\Controllers\Api;

use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Controllers\Controller;

class ProductController extends Controller
{
    public function show(Request $request, Product $product)
    {
        $etag = sha1($product->id . '|' . $product->updated_at);

        if ($request->header('If-None-Match') === '"' . $etag . '"') {
            return response(null, Response::HTTP_NOT_MODIFIED)
                ->header('ETag', '"' . $etag . '"');
        }

        $payload = [
            'id' => $product->id,
            'name' => $product->name,
            'price' => $product->price,
        ];

        return response()->json($payload)
            ->header('ETag', '"' . $etag . '"')
            ->header('Cache-Control', 'private, max-age=0, must-revalidate')
            ->header('Last-Modified', $product->updated_at->toRfc7231String());
    }
}

For collection endpoints, hash the query fingerprint plus the max updated_at in the result set. If any row changes, the ETag changes. This mirrors patterns from building RESTful APIs with Laravel.

Step 2: Use Laravel's built-in middleware

Laravel ships Illuminate\Http\Middleware\SetCacheHeaders. Apply it on routes that serve cacheable GET responses.

use Illuminate\Http\Middleware\SetCacheHeaders;

Route::get('/products/{product}', [ProductController::class, 'show'])
    ->middleware(SetCacheHeaders::using([
        'private',
        'max_age' => 0,
        'must_revalidate' => true,
    ]));

The middleware sets Cache-Control. You still own ETag generation and the 304 branch. Do not assume middleware alone handles conditional logic.

Step 3: Wire middleware for Last-Modified

When your model has a reliable updated_at, compare timestamps instead of hashing.

public function show(Request $request, Article $article)
{
    $lastModified = $article->updated_at;

    if ($request->headers->has('If-Modified-Since')) {
        $since = \Carbon\Carbon::parse(
            $request->header('If-Modified-Since')
        );

        if ($since->greaterThanOrEqualTo($lastModified)) {
            return response()->noContent(Response::HTTP_NOT_MODIFIED)
                ->header('Last-Modified', $lastModified->toRfc7231String());
        }
    }

    return ArticleResource::make($article)
        ->response()
        ->header('Last-Modified', $lastModified->toRfc7231String());
}

Truncate timestamps to seconds. HTTP dates have second precision. Microseconds in PHP can cause false mismatches and unnecessary 200 responses.

Laravel Validator DecisionIncoming GET requestIf-None-Match?compare ETagIf-Modified-Since?compare timeReturn 304skip serialiserReturn 200fresh ETag headermatchmatchMismatch → 200 + new validators
Laravel controller logic for API Caching with ETag and Last-Modified: validate before hitting the database or API Resource layer.

ETag vs Last-Modified: Which Should Your API Use?

Both headers enable conditional GETs. They differ in precision, cost, and failure modes. Most production APIs I work on use ETag for entity endpoints and Last-Modified as a secondary hint.

CriteriaETagLast-Modified
PrecisionExact version token; detects any change you encodeSecond-level timestamp; misses same-second edits
Computation costRequires hash or version lookupFree if updated_at already exists
CDN supportExcellent; standard If-None-Match flowGood; clock skew can cause stale 304s
CollectionsStrong when you hash query + max updated_atWeak unless all rows share one timestamp
Best fitHigh-churn resources, legal documents, product feedsStatic-ish content with reliable timestamps

Strong ETags change when the representation changes. Weak ETags (W/"...") change when semantically equivalent content shifts. For JSON APIs, strong ETags are simpler. Clients do not need to understand your semantics.

On legal-tech portals where document metadata updates frequently, ETags tied to row version prevent serving outdated PDF metadata. A timestamp alone might miss two edits in the same second during bulk imports.

ETag vs Last-ModifiedETagAccuracy: HighCost: Hash computeBest for: volatile dataLast-ModifiedAccuracy: Second-levelCost: LowBest for: stable recordsRecommended: send both headersClients pick If-None-Match firstFallback to If-Modified-Since
ETag vs Last-Modified trade-offs for API Caching with ETag and Last-Modified on REST endpoints.

See also REST API design best practices in 2026 for broader caching and versioning guidance. Pair validators with explicit API versioning so clients know when breaking changes invalidate local stores entirely.

How Should You Configure CDN and Reverse-Proxy Caching?

Validators shine when a CDN or reverse proxy sits in front of your origin. The edge node stores the full 200 response once. Later conditional GETs may be answered at the edge if your Cache-Control allows it.

Cache-Control values that actually work for APIs

  1. private — only the client browser caches; use for authenticated user data
  2. public, max-age=60, stale-while-revalidate=30 — safe for anonymous catalogue reads
  3. no-store — disables caching entirely; required for sensitive tokens or PII
  4. must-revalidate — forces revalidation after max-age expires

Never mark personalised JSON as public. A CDN might serve one user's dashboard to another. For shared reference data—currency rates, court fee tables, static lookups—public caching with short max-age works well. Our Nepal forex rates tool pattern applies: data changes on a schedule, not per user.

When using Varnish or similar, configure pass rules for authenticated routes. Let anonymous GETs with validators cache at the edge. Details appear in reverse proxy and caching with Varnish.

Vary header for content negotiation

If your API returns different JSON based on Accept-Language or Accept, add Vary: Accept, Accept-Language. Without it, a CDN might serve English JSON to a Nepali-locale client. Validators must be computed per variant.

return response()->json($payload)
    ->header('ETag', '"' . $etag . '"')
    ->header('Vary', 'Accept, Accept-Language')
    ->header('Cache-Control', 'public, max-age=120, must-revalidate');

Gateway layers from Kong API gateway can strip or normalise headers. Test end-to-end with curl, not only unit tests inside Laravel.

What Mistakes Break API Caching with ETag and Last-Modified?

Validators look simple. Production breaks them in predictable ways. I've hit most of these during deployments on shared EC2 infrastructure.

Unstable ETags from random or time-based hashes

If your ETag includes now() or a random salt, every request looks changed. Clients never get 304. Hash only stable fields: id, updated_at, content checksum.

Ignoring authentication on shared cache keys

Two users requesting /api/me must not share an ETag. Scope validators per user or mark responses private. Review API authentication with Passport vs Sanctum before caching any authenticated route.

Applying validators to non-idempotent methods

ETags apply to GET and HEAD. POST, PUT, PATCH, and DELETE use If-Match for optimistic concurrency, not If-None-Match. Mixing them causes subtle bugs. Idempotency keys solve write retries; see API idempotency keys implementation.

Forgetting to invalidate after writes

When a PUT updates a product, the ETag must change on the next GET. If your write path bypasses Eloquent events or uses raw SQL, updated_at may not move. The client keeps a stale 304 chain until max-age expires.

Caching Pitfalls to AvoidUnstable ETagincludes random or time()Public on /meshared user cache keyMissing Varylocale mismatch at CDNStale updated_atraw SQL skips eventsFix: test with curl -H flagsUse /tools/json-formatter to inspect payloads
Production pitfalls that prevent API Caching with ETag and Last-Modified from returning valid 304 responses.

Testing checklist

Run these curls against staging before release:

# First fetch — capture ETag
curl -i https://api.example.com/api/v1/products/42

# Conditional fetch — expect 304
curl -i -H 'If-None-Match: "abc123"' \
  https://api.example.com/api/v1/products/42

# After update — expect 200 with new ETag
curl -i -H 'If-None-Match: "abc123"' \
  https://api.example.com/api/v1/products/42

Automate these checks in CI alongside testing and optimization workflows. A regression that drops ETag headers is silent until mobile data bills spike.

For booking APIs like Adventure Third Pole Trek, availability endpoints benefit from short max-age plus ETag. Inventory changes often. Validators prevent stale calendar slots without disabling client cache entirely.

Key Takeaways

  • Send ETag and Last-Modified on every cacheable GET; compare If-None-Match and If-Modified-Since before serialising.
  • Prefer strong ETags built from stable fields; never hash time() or random values into the token.
  • Mark authenticated responses Cache-Control: private; never let CDNs cache per-user JSON as public.
  • Add Vary when language or content negotiation changes the response body.
  • Combine HTTP validators with Redis for hot queries—see database query caching strategies for the app layer.
  • Verify with curl conditionals in CI; a missing header costs more than a failed unit test suggests.

People Also Ask

What is the difference between 304 Not Modified and a cached 200 response?

A 304 tells the client its stored copy is still valid. No body travels over the wire. A cached 200 at a CDN may never reach your origin, but the first request still fetched the full payload. Validators reduce bandwidth on repeat client requests even when no CDN is present.

Should APIs use strong or weak ETags?

Strong ETags are the default choice for JSON APIs. They change whenever the computed representation changes. Weak ETags suit byte-range requests on large files. Most Laravel JSON endpoints should emit strong ETags unless you have a specific reason for weak semantics.

Does ETag caching work with GraphQL?

HTTP caching applies to entire HTTP responses, not individual GraphQL fields. POST-based GraphQL queries rarely benefit from ETag. If you expose GraphQL over GET for read-only operations, validators can work, but REST remains simpler for cache-friendly public data.

How does rate limiting interact with conditional requests?

A 304 still counts as a request at your origin unless the CDN answers it. Price 304s lower than full 200s in your infrastructure budget, but do not assume they are free. Pair with sensible rate limits from rate limiting and API throttling in Laravel.

Ship Faster APIs with Proper HTTP Caching

API Caching with ETag and Last-Modified is the lowest-friction win for read-heavy REST services. You change a few headers and a branch in your controller. Clients, CDNs, and mobile apps do the rest. Start with your top five GET endpoints by traffic. Add deterministic ETags this sprint. Measure 304 rates in your access logs next week.

If you want help auditing an existing Laravel or Symfony API, review Laravel API best practices and broader performance work in improving web performance with caching strategies. For end-to-end delivery—including gateway config, CDN rules, and load testing—explore speed optimization services or contact us to plan a caching pass on your production API.

Frequently Asked Questions

Validator headers that let clients ask if a cached GET response is still valid. Unchanged resources return 304 Not Modified with no body instead of full JSON.

Your API emits ETag and Last-Modified on GET responses along with Cache-Control. On the next request, the client sends If-None-Match or If-Modified-Since. If the validator still matches the current resource state, respond with 304 Not Modified and no body—the client keeps its cached copy. If it differs, return 200 OK with fresh payload and updated validators. A common mistake is returning 304 with a body; clients ignore it. The round trip still hits your server unless a CDN serves the 304 from edge memory, but you skip serialisation and database reads.

Laravel 13 on PHP 8.3 or higher lets you compute a deterministic ETag from stable fields like primary key and updated_at, then compare it against If-None-Match in your controller before heavy Eloquent work. Return a 304 response with the ETag header when matched. Apply Illuminate\Http\Middleware\SetCacheHeaders on routes for Cache-Control, but you still own ETag generation and the 304 branch—middleware alone does not handle conditional logic. For Last-Modified, compare If-Modified-Since against updated_at using RFC 7231 dates. Truncate timestamps to seconds; microsecond precision causes false mismatches.

Both enable conditional GETs but differ in precision and cost. ETag is an exact version token detecting any change you encode; Last-Modified is second-level and can miss same-second edits. ETag requires a hash or version lookup; Last-Modified is free when updated_at exists. Most production APIs use ETag for entity endpoints and Last-Modified as a secondary hint. ETag works better for collections when you hash the query fingerprint plus max updated_at. On legal-tech portals with frequent document metadata updates, ETags tied to row version prevent serving outdated metadata that a timestamp alone might miss during bulk imports.

A 304 confirms the client's copy is still valid with no body. A CDN-cached 200 may skip your origin, but the first request still fetched the full payload.

Strong ETags are the default for JSON APIs. They change whenever the representation changes. Weak ETags suit byte-range requests on large files, not typical Laravel JSON endpoints.

Validators shine when a CDN or reverse proxy sits in front of your origin. The edge stores the full 200 response once; later conditional GETs may be answered at the edge if Cache-Control allows it. Configure Varnish pass rules for authenticated routes and let anonymous GETs with validators cache at the edge. Never mark personalised JSON as public—a CDN might serve one user's dashboard to another. For shared reference data like currency rates or court fee tables, public caching with short max-age works well. Test end-to-end with curl, not only unit tests, because gateway layers like Kong can strip or normalise headers.

Use private, max-age=0, must-revalidate for authenticated or user-specific data so only the client browser caches and must revalidate. For anonymous catalogue reads, public, max-age=60, stale-while-revalidate=30 is safe when the JSON is identical for all users. Use no-store entirely for sensitive tokens or PII. The must-revalidate directive forces revalidation after max-age expires. When your API returns different JSON based on Accept-Language or Accept, add Vary: Accept, Accept-Language so a CDN does not serve English JSON to a Nepali-locale client. Validators must be computed per content variant.

Unstable ETags from hashing now() or random salts make every request look changed, so clients never receive 304. Two users requesting /api/me must not share an ETag—scope validators per user or mark responses private. ETags apply to GET and HEAD only; POST, PUT, PATCH, and DELETE use If-Match for optimistic concurrency, not If-None-Match. After a PUT updates a resource, the ETag must change on the next GET; if your write path bypasses Eloquent events or uses raw SQL, updated_at may not move and clients keep a stale 304 chain until max-age expires.

They solve different layers of the same problem. Redis caches computed responses inside your application, skipping expensive aggregation queries at the origin. ETag and Last-Modified work between the client and your origin through standard HTTP semantics defined in RFC 7232. On a production Laravel application, product catalogue endpoints use ETags derived from updated_at plus row count while mobile clients cache responses locally. Use Redis for hot queries inside the app. Use ETags for client-side and CDN revalidation. They complement each other rather than compete.

HTTP caching applies to entire HTTP responses, not individual GraphQL fields. POST-based GraphQL queries rarely benefit from ETag because validators pair with GET semantics. If you expose GraphQL over GET for read-only operations, validators can work in theory, but REST remains simpler for cache-friendly public data. For read-heavy APIs where conditional requests are the goal, designing cacheable GET endpoints with explicit ETag and Last-Modified headers is far more predictable than trying to layer HTTP validators onto a POST-heavy GraphQL schema.

A 304 still counts as a request at your origin unless the CDN answers it at the edge. Price 304 responses lower than full 200 responses in your infrastructure budget, but do not assume they are free—they still consume server time for validator comparison even when you skip serialisation and database reads. Pair conditional caching with sensible rate limits from your API throttling setup. The savings appear in reduced bandwidth and skipped heavy Eloquent work, not in eliminating the request entirely unless an intermediary serves the 304.

Yes, but mark responses Cache-Control: private so intermediaries never cache per-user JSON as public. Two authenticated users requesting the same URL must not share an ETag if the payload differs by account. Scope validators per user or session. Review your authentication approach before caching any authenticated route. Ignoring authentication on shared cache keys is a predictable production break—review API authentication with Passport vs Sanctum before enabling validators on protected GET endpoints. Private caching lets mobile clients revalidate locally without exposing one user's data to another through a shared CDN cache key.

Do not omit validators on paginated list endpoints where each page changes independently. Hash the query fingerprint plus the max updated_at in the result set so the ETag changes when any row in that page's result changes. Last-Modified alone is weak for collections unless all rows share one timestamp. A common mistake is applying a single ETag across paginated pages that update at different times. For booking APIs with availability endpoints, short max-age plus ETag prevents stale calendar slots without disabling client cache entirely when inventory changes frequently.

Run curl conditionals against staging before release. First fetch captures the ETag from response headers. Send a second request with If-None-Match set to that value and expect 304 Not Modified. After updating the resource, repeat with the old ETag and expect 200 with a new validator. Automate these checks in CI alongside your testing workflows—a regression that drops ETag headers is silent until mobile data bills spike. Verify both entity and collection endpoints, and test through any CDN or API gateway in the path since those layers can strip headers your Laravel controller sets correctly.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: