diff --git a/doc/interop-test-descriptions.md b/doc/interop-test-descriptions.md
index 1e04966380ce2841a8bbb997937a9e74a1eaf5f4..666af185b9f6b04feef631d730082dbb9898b717 100644
--- a/doc/interop-test-descriptions.md
+++ b/doc/interop-test-descriptions.md
@@ -60,6 +60,35 @@ Client asserts:
 *It may be possible to use UnaryCall instead of EmptyCall, but it is harder to
 ensure that the proto serialized to zero bytes.*
 
+### cacheable_unary
+
+This test verifies that gRPC requests marked as cacheable use GET verb instead
+of POST, and that server sets appropriate cache control headers for the response
+to be cached by a proxy. This test requires that the server is behind
+a caching proxy. Use of current timestamp in the request prevents accidental
+cache matches left over from previous tests.
+
+Server features:
+* [CacheableUnaryCall][]
+
+Procedure:
+ 1. Client calls CacheableUnaryCall with `SimpleRequest` request with payload
+    set to current timestamp. Timestamp format is irrelevant, and resolution is
+    in nanoseconds.
+    Client adds a `x-user-ip` header with value `1.2.3.4` to the request.
+    This is done since some proxys such as GFE will not cache requests from
+    localhost.
+    Client marks the request as cacheable by setting the cacheable flag in the
+    request context. Longer term this should be driven by the method option
+    specified in the proto file itself.
+ 2. Client calls CacheableUnaryCall with `SimpleRequest` request again
+    immediately with the same payload as the previous request. Cacheable flag is
+    also set for this request's context.
+
+Client asserts:
+* Both calls were successful
+* The payload body of both responses is the same.
+
 ### large_unary
 
 This test verifies unary calls succeed in sending messages, and touches on flow
@@ -941,6 +970,18 @@ payload body of size `SimpleRequest.response_size` bytes and type as appropriate
 for the `SimpleRequest.response_type`. If the server does not support the
 `response_type`, then it should fail the RPC with `INVALID_ARGUMENT`.
 
+### CacheableUnaryCall
+
+Server gets the default SimpleRequest proto as the request. The content of the
+request is ignored. It returns the SimpleResponse proto with the payload set
+to current timestamp.  The timestamp is an integer representing current time
+with nanosecond resolution. This integer is formated as ASCII decimal in the
+response. The format is not really important as long as the response payload
+is different for each request. In addition it adds
+  1. cache control headers such that the response can be cached by proxies in
+     the response path. Server should be behind a caching proxy for this test
+     to pass. Currently we set the max-age to 60 seconds.
+
 ### CompressedResponse
 [CompressedResponse]: #compressedresponse
 
diff --git a/src/proto/grpc/testing/test.proto b/src/proto/grpc/testing/test.proto
index 84369db4b8a6e93979700c420a7e12c10cd093c1..b52c4cbad6ccfa8561e38021a2f0315fbbe0c284 100644
--- a/src/proto/grpc/testing/test.proto
+++ b/src/proto/grpc/testing/test.proto
@@ -47,6 +47,11 @@ service TestService {
   // One request followed by one response.
   rpc UnaryCall(SimpleRequest) returns (SimpleResponse);
 
+  // One request followed by one response. Response has cache control
+  // headers set such that a caching HTTP proxy (such as GFE) can
+  // satisfy subsequent requests.
+  rpc CacheableUnaryCall(SimpleRequest) returns (SimpleResponse);
+
   // One request followed by a sequence of responses (streamed download).
   // The server returns the payload with client desired type and sizes.
   rpc StreamingOutputCall(StreamingOutputCallRequest)
diff --git a/test/cpp/interop/client.cc b/test/cpp/interop/client.cc
index 032b378b1a171b6d23669cac45cc0c517a1b2234..999be9d8a3f03cac3ea48619705a1b5323ebb9ef 100644
--- a/test/cpp/interop/client.cc
+++ b/test/cpp/interop/client.cc
@@ -149,6 +149,8 @@ int main(int argc, char** argv) {
     client.DoStatusWithMessage();
   } else if (FLAGS_test_case == "custom_metadata") {
     client.DoCustomMetadata();
+  } else if (FLAGS_test_case == "cacheable_unary") {
+    client.DoCacheableUnary();
   } else if (FLAGS_test_case == "all") {
     client.DoEmpty();
     client.DoLargeUnary();
@@ -166,6 +168,7 @@ int main(int argc, char** argv) {
     client.DoEmptyStream();
     client.DoStatusWithMessage();
     client.DoCustomMetadata();
+    client.DoCacheableUnary();
     // service_account_creds and jwt_token_creds can only run with ssl.
     if (FLAGS_use_tls) {
       grpc::string json_key = GetServiceAccountJsonKey();
@@ -177,6 +180,7 @@ int main(int argc, char** argv) {
     // compute_engine_creds only runs in GCE.
   } else {
     const char* testcases[] = {"all",
+                               "cacheable_unary",
                                "cancel_after_begin",
                                "cancel_after_first_response",
                                "client_compressed_streaming",
diff --git a/test/cpp/interop/interop_client.cc b/test/cpp/interop/interop_client.cc
index 6117878a33f74e89aab029fbb3c93712b4b49f49..e9a804ccae728b947db779810e521bd2f60252a2 100644
--- a/test/cpp/interop/interop_client.cc
+++ b/test/cpp/interop/interop_client.cc
@@ -846,6 +846,50 @@ bool InteropClient::DoStatusWithMessage() {
   return true;
 }
 
+bool InteropClient::DoCacheableUnary() {
+  gpr_log(GPR_DEBUG, "Sending RPC with cacheable response");
+
+  // Create request with current timestamp
+  gpr_timespec ts = gpr_now(GPR_CLOCK_PRECISE);
+  std::string timestamp = std::to_string((long long unsigned)ts.tv_nsec);
+  SimpleRequest request;
+  request.mutable_payload()->set_body(timestamp.c_str(), timestamp.size());
+
+  // Request 1
+  ClientContext context1;
+  SimpleResponse response1;
+  context1.set_cacheable(true);
+  // Add fake user IP since some proxy's (GFE) won't cache requests from
+  // localhost.
+  context1.AddMetadata("x-user-ip", "1.2.3.4");
+  Status s1 =
+      serviceStub_.Get()->CacheableUnaryCall(&context1, request, &response1);
+  if (!AssertStatusOk(s1)) {
+    return false;
+  }
+  gpr_log(GPR_DEBUG, "response 1 payload: %s",
+          response1.payload().body().c_str());
+
+  // Request 2
+  ClientContext context2;
+  SimpleResponse response2;
+  context2.set_cacheable(true);
+  context2.AddMetadata("x-user-ip", "1.2.3.4");
+  Status s2 =
+      serviceStub_.Get()->CacheableUnaryCall(&context2, request, &response2);
+  if (!AssertStatusOk(s2)) {
+    return false;
+  }
+  gpr_log(GPR_DEBUG, "response 2 payload: %s",
+          response2.payload().body().c_str());
+
+  // Check that the body is same for both requests. It will be the same if the
+  // second response is a cached copy of the first response
+  GPR_ASSERT(response2.payload().body() == response1.payload().body());
+
+  return true;
+}
+
 bool InteropClient::DoCustomMetadata() {
   const grpc::string kEchoInitialMetadataKey("x-grpc-test-echo-initial");
   const grpc::string kInitialMetadataValue("test_initial_metadata_value");
diff --git a/test/cpp/interop/interop_client.h b/test/cpp/interop/interop_client.h
index eb886fcb7e2c3126d0557e8c4af022bf29f8a2f2..1e89f0987d53a1668772023b435c4e252c35cf1c 100644
--- a/test/cpp/interop/interop_client.h
+++ b/test/cpp/interop/interop_client.h
@@ -79,6 +79,7 @@ class InteropClient {
   bool DoEmptyStream();
   bool DoStatusWithMessage();
   bool DoCustomMetadata();
+  bool DoCacheableUnary();
   // Auth tests.
   // username is a string containing the user email
   bool DoJwtTokenCreds(const grpc::string& username);
diff --git a/test/cpp/interop/interop_server.cc b/test/cpp/interop/interop_server.cc
index c05eb5d1461be32918df80dfd59606f01510999b..05af18bc6711af2bcb357bba882a61d8cb3611d0 100644
--- a/test/cpp/interop/interop_server.cc
+++ b/test/cpp/interop/interop_server.cc
@@ -47,6 +47,7 @@
 #include <grpc/support/log.h>
 #include <grpc/support/useful.h>
 
+#include "src/core/lib/support/string.h"
 #include "src/core/lib/transport/byte_stream.h"
 #include "src/proto/grpc/testing/empty.grpc.pb.h"
 #include "src/proto/grpc/testing/messages.grpc.pb.h"
@@ -153,6 +154,16 @@ class TestServiceImpl : public TestService::Service {
     return Status::OK;
   }
 
+  // Response contains current timestamp. We ignore everything in the request.
+  Status CacheableUnaryCall(ServerContext* context, const SimpleRequest* request,
+                   SimpleResponse* response) {
+    gpr_timespec ts = gpr_now(GPR_CLOCK_PRECISE);
+    std::string timestamp = std::to_string((long long unsigned)ts.tv_nsec);
+    response->mutable_payload()->set_body(timestamp.c_str(), timestamp.size());
+    context->AddInitialMetadata("cache-control", "max-age=60, public");
+    return Status::OK;
+  }
+
   Status UnaryCall(ServerContext* context, const SimpleRequest* request,
                    SimpleResponse* response) {
     MaybeEchoMetadata(context);