When a client sends an HTTP request to the server, the request header is automatically populated with 'Accept-Language', which helps the server side determine the client's Locale. This header doesn't carry any client time zone information. There's currently no standard HTTP header or specification for obtaining a client's time zone information.
Spring support for Time zone
Definition of LocaleContextResolverVersion: 7.0.6 package org.springframework.web.servlet;
public interface LocaleContextResolver extends LocaleResolver {
LocaleContext resolveLocaleContext(HttpServletRequest request); 1
void setLocaleContext(HttpServletRequest request, 2
@Nullable HttpServletResponse response,
@Nullable LocaleContext localeContext);
default Locale resolveLocale(HttpServletRequest request); 3
default void setLocale(HttpServletRequest request, 4
@Nullable HttpServletResponse response,
@Nullable Locale locale);
}
Definition of TimeZoneAwareLocaleContextVersion: 7.0.6 package org.springframework.context.i18n;
public interface TimeZoneAwareLocaleContext extends LocaleContext {
TimeZone getTimeZone(); 1
}
The LocaleContextResolver interface may include time zone information. Spring currently provides three implementations of this interface: CookieLocaleResolver, FixedLocaleResolver and SessionLocaleResolver. In the previous four tutorials, we saw how to use them to obtain the client's Locale. Any of these classes can be used to set the time zone via this method:
void setLocaleContext(HttpServletRequest request,
HttpServletResponse response,
LocaleContext localeContext)
Handler method support
A controller's handler method can have java.util.TimeZone or java.time.ZoneId parameters. These parameters are populated with corresponding values as determined by the active LocaleContextResolver. If no time zone is set via LocaleContextResolver#setLocaleContext() then the system's default time zone will be used. Note that this fallback only applies to handler method parameters: directly calling LocaleContextResolver#resolveLocaleContext() and reading its time zone will return null unless a time zone was explicitly set beforehand via LocaleContextResolver#setLocaleContext().
The following snippet is from ServletRequestMethodArgumentResolver.java which resolves the handler method arguments:
.....
public Object resolveArgument( ...... ) throws Exception {
.....
else if (TimeZone.class == paramType) {
TimeZone timeZone = RequestContextUtils.getTimeZone(request);
return (timeZone != null ? timeZone : TimeZone.getDefault());
}
else if (ZoneId.class == paramType) {
TimeZone timeZone = RequestContextUtils.getTimeZone(request);
return (timeZone != null ? timeZone.toZoneId() : ZoneId.systemDefault());
}
.....
}
...
The following handler will always print the system's (the server computer's) time zone and zone id, regardless of what time zone the client is in:
@RequestMapping("/test")
public void exampleHandler (TimeZone tz, ZoneId zid) {
System.out.println(tz);
System.out.println(zid);
}
So how to obtain client time zone?
One solution for obtaining the client's time zone is to use client-side JavaScript's Date API:
<script>
var date = new Date()
var offset = date.getTimezoneOffset()
</script>
The browser then sends this offset from JavaScript to the server side so that Spring can apply it via LocaleContextResolver#setLocaleContext(). Let's see an example of how to achieve that.
Note: getTimezoneOffset() returns the number of minutes behind UTC (positive west of UTC), which is the opposite sign convention from a standard UTC offset — that's why the example below negates it when constructing a ZoneOffset. Also, because this API returns only a numeric offset, it cannot distinguish between zones that currently share the same offset, and it doesn't carry daylight-saving rules the way an IANA zone id does. In modern browsers, Intl.DateTimeFormat().resolvedOptions().timeZone is generally the better choice, since it returns an actual IANA zone id (e.g. "America/Chicago") instead of a raw offset. The offset-based approach is kept here because it's what this example's server-side flow is built around.
Example
Creating a custom LocaleContextResolver
A custom LocaleContextResolver implementation will delegate Locale responsibilities to AcceptHeaderLocaleResolver and time zone responsibility to one of Spring's other implementations of LocaleContextResolver:
package com.logicbig.example;
import org.springframework.context.i18n.LocaleContext;
import org.springframework.web.servlet.LocaleContextResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Locale;
public class AcceptHeaderLocaleTzCompositeResolver implements LocaleContextResolver {
private LocaleContextResolver localeContextResolver;
private AcceptHeaderLocaleResolver acceptHeaderLocaleResolver;
public AcceptHeaderLocaleTzCompositeResolver (LocaleContextResolver localeContextResolver) {
this.localeContextResolver = localeContextResolver;
acceptHeaderLocaleResolver = new AcceptHeaderLocaleResolver();
acceptHeaderLocaleResolver.setDefaultLocale(Locale.getDefault());
}
@Override
public LocaleContext resolveLocaleContext (HttpServletRequest request) {
return localeContextResolver.resolveLocaleContext(request);
}
@Override
public void setLocaleContext (HttpServletRequest request, HttpServletResponse response,
LocaleContext localeContext) {
localeContextResolver.setLocaleContext(request, response, localeContext);
}
@Override
public Locale resolveLocale (HttpServletRequest request) {
return acceptHeaderLocaleResolver.resolveLocale(request);
}
@Override
public void setLocale (HttpServletRequest request, HttpServletResponse response, Locale locale) {
acceptHeaderLocaleResolver.setLocale(request, response, locale);
}
}
Creating a custom HandlerInterceptor
A custom HandlerInterceptor implementation will intercept each request and try to obtain a TimeZone instance. If it is null, the request is forwarded to a handler.
Note: HandlerInterceptorAdapter was deprecated in Spring 5.3 and removed in Spring 6. Since HandlerInterceptor's methods are now default methods on the interface itself, you can implement HandlerInterceptor directly, as shown below.
package com.logicbig.example;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.support.RequestContextUtils;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.TimeZone;
public class TzRedirectInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle (HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
TimeZone tz = RequestContextUtils.getTimeZone(request);
if (tz == null) {
System.out.println("Forwarding to js to get timezone offset");
request.setAttribute("requestedUrl", request.getRequestURI());
RequestDispatcher dispatcher = request.getRequestDispatcher("/tzHandler");
dispatcher.forward(request, response);
return false;
}
return true;
}
}
RequestContextUtils is a helper class. The call above gets the time zone from the underlying LocaleContextResolver.
The handler method with '/tzHandler' mapping:
The above interceptor forwards the request to this handler.
@Controller
public class TzExampleController {
.............
@GetMapping("/tzHandler")
public String handle() {
return "tzJsPage";
}
.............
}
tzJsPage.jsp
This contains JavaScript to obtain the client's time zone offset. This page directly redirects to another URI /tzValueHandler without any user interaction:
src/main/webapp/WEB-INF/views/tzJsPage.jsp<%@ page language="java"
contentType="text/html; charset=ISO-8859-1"
pageEncoding="ISO-8859-1"%>
<html>
<body>
<form method="post" id="tzForm" action="/tzValueHandler">
<input id="tzInput" type="hidden" name="timeZoneOffset"><br>
<input type="hidden" name="requestedUrl" value="${requestedUrl}">
</form>
<script>
var date = new Date();
var offSet = date.getTimezoneOffset();
document.getElementById("tzInput").value = offSet;
document.getElementById("tzForm").submit();
</script>
</body>
</html>
The handler method with '/tzValueHandler' mapping
This handler sets the time zone instance on the underlying LocaleContextResolver:
package com.logicbig.example;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.i18n.SimpleTimeZoneAwareLocaleContext;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.LocaleContextResolver;
import org.springframework.web.servlet.support.RequestContextUtils;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Locale;
import java.util.TimeZone;
@Controller
public class TzExampleController {
.............
@PostMapping(value = "/tzValueHandler")
public String handleTzValue(
Locale locale,
HttpServletRequest req,
HttpServletResponse res,
@RequestParam("requestedUrl") String requestedUrl,
@RequestParam("timeZoneOffset") int timeZoneOffset) {
ZoneOffset zoneOffset =
ZoneOffset.ofTotalSeconds(-timeZoneOffset * 60);
TimeZone timeZone = TimeZone.getTimeZone(zoneOffset);
LocaleContextResolver localeResolver =
(LocaleContextResolver) RequestContextUtils.getLocaleResolver(
req);
localeResolver.setLocaleContext(req, res,
new SimpleTimeZoneAwareLocaleContext(
locale, timeZone));
return "redirect:" + requestedUrl;
}
}
Handler method for testing
package com.logicbig.example;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.i18n.SimpleTimeZoneAwareLocaleContext;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.LocaleContextResolver;
import org.springframework.web.servlet.support.RequestContextUtils;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.Locale;
import java.util.TimeZone;
@Controller
public class TzExampleController {
@GetMapping("/")
@ResponseBody
public String testHandler(Locale clientLocale,
ZoneId clientZoneId) {
ZoneOffset serverZoneOffset = ZoneOffset.ofTotalSeconds(
TimeZone.getDefault().getRawOffset() / 1000);
return String.format("client timeZone: %s" +
"<br/> " +
"server timeZone: %s" +
"<br/>" +
" locale: %s%n",
clientZoneId.normalized().getId(),
serverZoneOffset.getId(),
clientLocale);
}
.............
}
Configuration
@EnableWebMvc
@ComponentScan
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
LocaleContextResolver localeResolver() {
SessionLocaleResolver l = new SessionLocaleResolver();
AcceptHeaderLocaleTzCompositeResolver r = new
AcceptHeaderLocaleTzCompositeResolver(l);
return r;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
TzRedirectInterceptor interceptor = new TzRedirectInterceptor();
InterceptorRegistration i = registry.addInterceptor(interceptor);
i.excludePathPatterns("/tzHandler", "/tzValueHandler");
}
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.jsp("/WEB-INF/views/", ".jsp");
}
}
Running the example
To try examples, run embedded Jetty (configured in pom.xml of example project below):
mvn jetty:run
Since in a development environment both client and server are most likely running on the same computer, we'll use the Firefox browser, which allows changing the browser's time zone via an environment variable. Run cmd.exe:
C:\Program Files\Mozilla Firefox>set TZ=EST5EDT
C:\Program Files\Mozilla Firefox>firefox.exe
C:\Program Files\Mozilla Firefox>
In the console where we ran the Jetty server, a line will be printed: 'Forwarding to js to get timezone offset' This line is printed by our interceptor. Refreshing the same page multiple times won't print the same line again, because on further access the time zone is retrieved from the HttpSession, and the request is no longer forwarded to the JavaScript logic.
Using CookieLocaleResolver and FixedLocaleResolver
In the example above we used SessionLocaleResolver (as the LocaleContextResolver delegate specific to time zone handling) with our AcceptHeaderLocaleTzCompositeResolver. The server side will remember the time zone set by the JavaScript logic until the end of the session. If we want it remembered for longer or shorter than the session's lifetime, we should use CookieLocaleResolver for time zone handling.
If we want to use a fixed time zone instead of the system's time zone, we should use FixedLocaleResolver with our AcceptHeaderLocaleTzCompositeResolver; in that case we won't need our custom TzRedirectInterceptor and JavaScript logic. The fixed time zone value is set at registration time in the main class.
Across all the options just mentioned, the locale selection strategy remains the same in our example, i.e. AcceptHeaderLocaleResolver. If we want to change that too, we don't need a composite LocaleContextResolver — a single LocaleContextResolver will do the job, unless we want to apply different strategies for locale and time zone separately. For locale handling with a resolver other than AcceptHeaderLocaleResolver, we have to provide a custom locale selection as well, as shown in this and this example. The time zone can also be selected via a user interface instead of using the JavaScript Date API.
Integration Test
package com.logicbig.example;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitWebConfig(WebConfig.class)
public class ClientTimeZoneTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvcTester mockMvcTester;
@BeforeEach
public void setup() {
mockMvcTester = MockMvcTester.from(webApplicationContext);
}
@Test
public void firstRequestWithNoTimeZoneGetsForwardedToJsHandler() {
assertThat(mockMvcTester.get()
.uri("/")
.session(new MockHttpSession())
.exchange())
.hasStatusOk()
.hasForwardedUrl("/tzHandler");
}
@Test
public void timeZonePersistsInSessionAcrossRequests() {
MockHttpSession session = new MockHttpSession();
assertThat(mockMvcTester.post()
.uri("/tzValueHandler")
.session(session)
.param("timeZoneOffset", "120")
.param("requestedUrl", "/")
.exchange())
.hasStatus3xxRedirection()
.hasRedirectedUrl("/");
assertThat(mockMvcTester.get().uri("/")
.session(session)
.exchange())
.hasStatusOk()
.bodyText().contains("client timeZone: -02:00");
}
}
Example ProjectDependencies and Technologies Used: - spring-webmvc 7.0.6 (Spring Web MVC)
Version Compatibility: 4.3.0.RELEASE - 7.0.6 Version compatibilities of spring-webmvc with this example: Versions in green have been tested.
- spring-test 7.0.6 (Spring TestContext Framework)
- junit-jupiter-engine 6.0.3 (Module "junit-jupiter-engine" of JUnit)
- jakarta.servlet-api 6.1.0 (Jakarta Servlet API documentation)
- hamcrest 3.0 (Core API and libraries of hamcrest matcher framework)
- assertj-core 3.26.3 (Rich and fluent assertions for testing in Java)
- JDK 25
- Maven 3.9.11
|