In this blog post, I’ll show you how to easily and effectively implement time zone support, with Daylight Savings Time (DST) awareness, in your web applications (Angular with ASP.NET Core).
Historically, these issues have always stuck a thorn in the side of application development. Even if all your users are in the same time zone, Daylight Savings Time (DST) will still pose a difficult challenge (if the time zone supports DST). Because even within a single time zone, the difference between 8:00am one day and 8:00am the next day will actually be 23 hours or 25 hours (depending on whether DST had started or ended overnight in between), and not 24 hours like it is in all other instances throughout the year.
When your application is storing and retrieving historical date/time values, absolute precision is critical. There can be no ambiguity around the exact moment an entry was recorded in the system, regardless of the local time zone or DST status at any tier; the client (browser), application (web service), or database server.
General consensus therefore is to always store date/time values in the database as UTC (coordinated universal time), a zero-offset value that never uses DST. Likewise, all date/time manipulation performed by the application tier in the web service is all in UTC. By treating all date/time values as UTC on the back end (from the API endpoint through the database persistence layer), your application is accurately recording the precise moment that an event occurs, regardless of the time zone or DST status at the various tiers of the running application.
Note: UTC is often conflated with GMT (Greenwich Mean Time), since they both represent a +00:00 hour/minute offset. However, GMT is a time zone that happens to align with UTC, while UTC is a time standard for the +00:00 offset that never uses DST. Unlike UTC, some of the countries that use GMT switch to different time zones during their DST period.
Note: SQL Server 2008 introduced the datetimeoffset data type (also supported in Azure SQL Database). This data type stores date/time values as UTC internally, but also embeds the local time zone offset to UTC in each datetimeoffset instance. Thus, date/time values appear to get stored and retrieved as local times with different UTC offsets, while under the covers, it’s still all UTC (so sorting and comparisons work as you’d expect). This sounds great, but unfortunately, the datetimeoffset data type is not recommended for two reasons; it lacks .NET support (it is not a CLS-compliant data type), and it is not DST-aware. This is why, for example, the SQL Server temporal feature (offering point-in-time access to any table) uses the datetime2 data type to persist date/time values that are all stored in UTC.
Once you store all your date/time values as UTC, you have accurate data on the back end. But now, how do you adapt to the local user’s time zone and DST status? Users will want to enter and view data in their local time zone, with DST adjusted based on that time zone. Or, you might want to configure your application with a designated “site” time zone for all users regardless of their location. In either scenario, you need to translate all date/time values that are received in client requests from a non-UTC time zone that may or may not be respecting DST at the moment, into UTC for the database. Likewise, you need to translate all date/time values in the responses that are returned to the client from UTC to their local time zone and current DST status.
Of course, it’s possible to perform these conversions in your application, by including the current local time zone offset appended as part of the ISO-formatted value in each API request; for example, 2021-02-05T08:00:00-05:00, where -05:00 represents the five hour offset for Eastern Standard Time (EST) here in New York. Unfortunately, here are three complications with this approach.
The first problem is that it only works for current points in time. For example, in July, EST respects DST, and so the offset is only four hours from UTC, not five; for example, 2021-07-05T08:00:00-04:00. This translates fine, since the current offset in July is actually four hours and not five. But if, in July, the user is searching for 8:00am on some past date in February, then the value 2021-02-05T08:00:00-04:00 is off by an hour, which of course yields incorrect results. This means that you really need to know the actual time zone itself, not just the current offset.
Second, resolving DST remains a problem that’s not easily solved on the client. Because some time zones don’t respect DST at all, and those that do respect DST all switch to and from DST at different dates from year to year.
And last, the onus is then on every developer to include code that converts from local to UTC in the request, and from UTC to local in the response. Every date/time value instance must be accounted for in both directions, so it is all too easy for a developer to miss a spot here and there. The result, of course, is a system that’s both difficult to maintain and error prone.
The solution explained here addresses all these concerns by leveraging middleware in ASP.NET Core. This is a feature that allows you to intercept all incoming requests and modify the request content after it leaves the client, but before it reaches your endpoint. Likewise, you can intercept all outgoing responses and modify the response content before it gets returned to the client, but after you have finished processing the request.
Thus, we will write some middleware that discovers every date/time value found in the JSON body (or query string parameters in the URI) associated with the request, and converts it to UTC. Likewise, it discovers every date/time value found in the JSON body associated with the response, and converts it to the user’s local time zone. Of course, to properly perform the conversions with DST awareness, the server needs to know the local time zone (not just the current time zone offset). This is the only responsibility that falls on the client; it needs to inform the server what the local time zone is with every request. All the rest of the heavy processing happens in the middleware on the server.
Write an Angular Interceptor
This is fairly straightforward; we want to inject the local time zone into the HTTP header of every API request issued by our Angular client:
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
@Injectable()
export class TimeZoneInterceptorService implements HttpInterceptor {
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const modifiedReq = req.clone({
headers: req.headers.set('MyApp-Local-Time-Zone-Iana', Intl.DateTimeFormat().resolvedOptions().timeZone),
});
return next.handle(modifiedReq);
}
}
This class implements the HttpInterceptor
interface provided by Angular, which requires that you supply an intercept
method. It receives the incoming request in req
and modifies it by cloning it while adding a new header named MyApp-Local-Time-Zone-Iana
with the value returned by Intl.DateTimeFormat().resolvedOptions().timeZone
. That mouthful simply returns the client’s local time zone in IANA format, one of several different standards for expressing time zones. For example, here in New York, the IANA-formatted time zone is America/New_York
.
Configure the Middleware Class
In Starup.cs
, add code to the Configure
method to plug in a class that we’ll call RequestResponseTimeZoneConverter
. As the name implies, this class will convert incoming and outgoing date/time values to and from UTC and the client’s local time zone:
public void Configure(IApplicationBuilder app)
{
// :
app.UseMiddleware<RequestResponseTimeZoneConverter>();
// :
}
Implement the Invoke method
Our middleware class needs to implement the Invoke
method that will fire for each request, which can process each incoming request and outgoing response. Start with the namespace imports and class definition like so:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using OregonLC.Shared;
using OregonLC.Shared.Extensions;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using TimeZoneConverter;
namespace TimeZoneDemo
{
public class RequestResponseTimeZoneConverter
{
private readonly AppConfig _appConfig;
private readonly RequestDelegate _next;
public RequestResponseTimeZoneConverter(
IOptions<AppConfig> appConfig,
RequestDelegate next)
{
this._appConfig = appConfig.Value;
this._next = next;
}
}
}
Notice that we’re also injecting IOptions<AppConfig>
for a strongly typed configuration object based on appsettings.json
, where we define settings that can influence the time zone conversion behavior of our middleware.
Now implement the Invoke
method:
public async Task Invoke(HttpContext context)
{
// Request content and parameters won't be modified if disableTimeZoneConversion=true is specified
// as a query string parameter in the URI
var disableConversion =
context.Request.Query.ContainsKey("disableTimeZoneConversion") &&
context.Request.Query["disableTimeZoneConversion"] == "true";
// Get the local time zone for UTC conversion
var localTimeZone = this.GetLocalTimeZone(context);
// If conversion isn't disabled, and the local time zone can be detected (and isn't UTC),
// modify the request content (convert local to UTC)
if (!disableConversion && localTimeZone != null && localTimeZone.Id != "UTC")
{
// Modify the date/time request parameters in the URI
this.ModifyRequestParameters(context, localTimeZone);
// Don't modify the request content unless the Content-Type is application/json
var isJsonContent =
context.Request.Headers.ContainsKey("Content-Type") &&
context.Request.Headers["Content-Type"] == "application/json";
if (isJsonContent)
{
// Modify the date/time properties in the request content
await this.ModifyRequestContent(context, localTimeZone);
}
}
// Prepare for modifying the response body
var responseStream = context.Response.Body;
var modifiedResponseStream = new MemoryStream();
context.Response.Body = modifiedResponseStream;
try
{
await this._next(context).ConfigureAwait(false);
}
finally
{
context.Response.Body = responseStream;
}
// Modify the response content (convert UTC to local)
modifiedResponseStream = this.ModifyResponseContent(context, disableConversion, localTimeZone, modifiedResponseStream);
await modifiedResponseStream.CopyToAsync(responseStream).ConfigureAwait(false);
}
private TimeZoneInfo GetLocalTimeZone(HttpContext context)
{
// If the app config doesn't permit multiple time zones, then treat every user as if
// they were in the same "site" time zone
if (!this._appConfig.SupportMultipleTimeZones)
{
return TimeZoneInfo.FindSystemTimeZoneById(this._appConfig.SiteTimeZoneId);
}
// If the request headers include the user's local time zone (IANA name, injected by client-side HTTP interceptor),
// use that time zone
if (context.Request.Headers.TryGetValue("MyApp-Local-Time-Zone-Iana", out StringValues localTimeZoneIana))
{
return TZConvert.GetTimeZoneInfo(localTimeZoneIana);
}
// The app config permits multiple time zones, but the user request doesn't specify the time zone
return null;
}
The code is heavily commented to make it self-describing, so I’ll just call out the main aspects. We’ll allow the client to disable time zone conversion on a per-request basis, by supplying disableTimeZoneConversion
=true
as a query string parameter. We then call GetLocalTimeZone
, which first checks our app configuration to see if the server application wants to support multiple time zones, or a consistent “site” time zone for all clients. So if we see that SupportMultipleTimeZones
is false, then we ignore the client’s local time zone injected by our Angular interceptor, and use the same “site” time zone for all users instead, as defined by SiteTimeZoneId
in the app configuration.
Otherwise, we get the client’s local time zone by retrieving the MyApp-Local-Time-Zone-Iana
custom HTTP header injected by the interceptor. Because .NET requires the time zone in Windows format, we use the TZConvert.GetTimeZoneInfo
method (available by installing the TimeZoneConverter
NuGet package) to convert from the IANA format supplied by the client into a Windows TimeZoneInfo
object. For example, it will convert America/New_York
to (UTC-05:00) Eastern Time (US & Canada)
.
Then, as long as this client request hasn’t explicitly disabled time conversion, and the client’s local time zone itself isn’t UTC (meaning no conversion is necessary anyway), we call ModifyRequestParameters
to convert all date/time values supplied as query string parameters in the request URI to UTC. And then, after verifying that the request content being posted to our API is, in fact, a JSON payload, we call ModifyRequestContent
to convert all date/time values inside that JSON content to UTC as well.
Before allowing the request to process, we capture the current response stream in case of exception, and set the response body to a new stream for our modified response. We then call this._next
to invoke the request, with exception handling in place to use the original response stream in case an error occurs processing the request. Otherwise, we call ModifyResponseContent
to convert all date/time values inside the JSON response payload from UTC back to the client’s local time zone.
Modify the Request Query String Parameters (local to UTC)
Now add the ModifyRequestParameters
method to handle query string parameters:
private void ModifyRequestParameters(HttpContext context, TimeZoneInfo localTimeZone)
{
// Get all the query parameters from the URI
var queryParameters = context.Request.Query
.SelectMany(kvp =>
kvp.Value, (col, value) =>
new KeyValuePair<string, string>(col.Key, value))
.ToList();
// Nothing to do if there aren't any
if (queryParameters.Count == 0)
{
return;
}
// Build a new list of query parameters, converting date/time values
var modifiedQueryParameters = new List<KeyValuePair<string, string>>();
var modified = false;
foreach (var item in queryParameters)
{
var value = item.Value;
if (value.FromDateTimeIsoString(out DateTime local))
{
var utc = TimeZoneInfo.ConvertTimeToUtc(local, localTimeZone);
value = utc.ToDateTimeIsoString();
var modifiedQueryParameter = new KeyValuePair<string, string>(item.Key, value);
modifiedQueryParameters.Add(modifiedQueryParameter);
modified = true;
}
else
{
var unmodifiedQueryParameter = new KeyValuePair<string, string>(item.Key, value);
modifiedQueryParameters.Add(unmodifiedQueryParameter);
}
}
if (modified)
{
var qb = new QueryBuilder(modifiedQueryParameters);
context.Request.QueryString = qb.ToQueryString();
}
}
We first obtain all the query parameters from the request URI, from which we build a new list of parameters with any date/time values converted from local to UTC. This code relies on the two extension methods FromDateTimeIsoString
and ToDateTimeIsoString
to parse to and from ISO-formatted date/time strings and native .NET DateTime
types:
public static bool FromDateTimeIsoString(this string value, out DateTime dateTime)
{
if (
(value.Length == 16 || (value.Length == 19 && value[16] == ':')) &&
value[4] == '-' &&
value[7] == '-' &&
value[10] == 'T' &&
value[13] == ':' &&
DateTime.TryParse(value, out DateTime parsedDateTime) // calls DateTime.TryParse only after passing the smell test
)
{
dateTime = parsedDateTime;
return true;
}
dateTime = DateTime.MinValue;
return false;
}
public static string ToDateTimeIsoString(this DateTime value) =>
value.ToString("yyyy-MM-ddTHH:mm:ss");
Modify the Request Body Content (local to UTC)
Next add the ModifyRequestContent
method to handle the JSON request payload:
private async Task<TimeZoneInfo> ModifyRequestContent(HttpContext context, TimeZoneInfo localTimeZone)
{
// Read the request content from the request body stream; if it's a JSON object, we'll process it
var requestStream = context.Request.Body;
var originalRequestContent = await new StreamReader(requestStream).ReadToEndAsync();
// Try to get the JSON object from the request content
var jobj = originalRequestContent.TryDeserializeToJToken();
// If the request content is a JSON object, convert all of it's date/time properties from local time to UTC
var modified = false;
if (jobj != null)
{
modified = jobj.ConvertLocalToUtc(localTimeZone);
}
if (modified)
{
// Replace the stream with the updated request content
var json = JsonConvert.SerializeObject(jobj);
var requestContent = new StringContent(json, Encoding.UTF8, "application/json");
requestStream = await requestContent.ReadAsStreamAsync();
}
else
{
// Replace the stream with the original request content
requestStream = new MemoryStream(Encoding.UTF8.GetBytes(originalRequestContent));
}
// Replace the request body stream
context.Request.Body = requestStream;
// Return the time zone info for the reverse conversion on the response
return localTimeZone;
}
We first try to deserialize the JSON, and if that succeeds, we convert all of its date/time properties from local to UTC. We then replace the original stream with the updated content, unless we detect that there were no date/time properties at all in the request, in which case we continue with the original stream.
Again, we have some extension methods here to help out, which are TryDeserializeToJToken
and ConvertLocalToUtc
:
public static JToken TryDeserializeToJToken(this string json)
{
if (json == null || (!json.StartsWith("[") && !json.StartsWith("{")))
{
return null;
}
// Try to get the JSON object from the request content
var jToken = default(JToken);
try
{
jToken = JsonConvert.DeserializeObject<JToken>(json);
}
catch
{
// Ignore the exception, returning null to indicate bad JSON
}
return jToken;
}
public static bool ConvertLocalToUtc(this JToken token, TimeZoneInfo localTimeZone, bool wasModified = false)
{
var modified = wasModified;
if (token.Type == JTokenType.Object)
{
modified = ConvertLocalToUtcForObject(token, localTimeZone, wasModified, modified);
}
else if (token.Type == JTokenType.Array)
{
modified = ConvertLocalToUtcForArray(token, localTimeZone, wasModified, modified);
}
return modified;
}
private static bool ConvertLocalToUtcForObject(JToken token, TimeZoneInfo localTimeZone, bool wasModified, bool modified)
{
foreach (var prop in token.Children<JProperty>())
{
var child = prop.Value;
if (child is JValue jValue)
{
var value = ParseJsonValueForDateTime(jValue.Value);
if (value is DateTime)
{
var local = (DateTime)value;
var utc = TimeZoneInfo.ConvertTimeToUtc(local, localTimeZone);
jValue.Value = utc;
modified = true;
}
}
else if (child.HasValues)
{
modified = child.ConvertLocalToUtc(localTimeZone, wasModified) || modified;
}
}
return modified;
}
private static bool ConvertLocalToUtcForArray(JToken token, TimeZoneInfo localTimeZone, bool wasModified, bool modified)
{
foreach (var item in token.Children())
{
var child = item;
if (child.HasValues)
{
modified = child.ConvertLocalToUtc(localTimeZone, wasModified) || modified;
}
}
return modified;
}
Modify the Response Body Content (UTC to local)
The last part is the ModifyResponseContent
to handle the response, which converts date/time values from UTC back to the client’s local time zone:
private MemoryStream ModifyResponseContent(
HttpContext context,
bool disableConversion,
TimeZoneInfo localTimeZone,
MemoryStream responseStream)
{
// Rewind the unmodified response stream
responseStream.Position = 0;
var modified = false;
// Will capture the unmodified response for time zone conversion
var responseContent = default(string);
// Only attempt to modify the response if time zone conversion is not disabled
// and we have a local time zone that was used to modify the request
if (!disableConversion && localTimeZone != null)
{
// Capture the unmodified response
responseContent = new StreamReader(responseStream).ReadToEnd();
// Try to get the JSON object from the response content
var jobj = responseContent.TryDeserializeToJToken();
// If the response content is a JSON object, convert all of it's date/time properties from local time to UTC
if (jobj != null && jobj.ConvertUtcToLocal(localTimeZone))
{
responseContent = JsonConvert.SerializeObject(jobj);
modified = true;
}
}
// If no changes were made (i.e., there were no converted date/time properties),
// use the original unmodified response
if (!modified)
{
responseStream.Position = 0;
context.Response.ContentLength = responseStream.Length;
return responseStream;
}
// Write the changed response content to a new modified response stream
var modifiedResponseStream = new MemoryStream();
var sw = new StreamWriter(modifiedResponseStream);
sw.Write(responseContent);
sw.Flush();
modifiedResponseStream.Position = 0;
// Use the new modified response
context.Response.ContentLength = modifiedResponseStream.Length;
return modifiedResponseStream;
}
Here, we capture the unmodified response, attempt to deserialize its JSON, and if that succeeds, we convert all of its date/time values from UTC to local. We then return the modified response stream, unless no date/time values at all were present in the response, in which case we return the original unmodified response stream.
The actual time zone conversion happens inside the ConvertUtcToLocal
extension method:
private static bool ConvertUtcToLocalForObject(
JToken token,
TimeZoneInfo localTimeZone,
bool wasModified,
bool modified)
{
foreach (var prop in token.Children<JProperty>())
{
var child = prop.Value;
if (child is JValue jValue)
{
var value = ParseJsonValueForDateTime(jValue.Value);
if (value is DateTime)
{
var utc = (DateTime)value;
// Only convert if Kind is unspecified and the property name
// does not end in "Date" (i.e., it's a date/time, not just a date)
if (utc.Kind == DateTimeKind.Unspecified && !prop.Name.EndsWith("Date"))
{
var tz = TimeZoneInfo.FindSystemTimeZoneById(localTimeZone.Id);
var local = TimeZoneInfo.ConvertTimeFromUtc(utc, tz);
jValue.Value = local;
modified = true;
}
}
else if (prop.Name.EndsWith("Json") && value is string)
{
// Also handle JSON "embedded" in the response; i.e., string properties that contain JSON
var stringValue = value.ToString();
var embeddedJObject = stringValue.TryDeserializeToJToken();
if (embeddedJObject != null)
{
if (embeddedJObject.ConvertUtcToLocal(localTimeZone))
{
jValue.Value = JsonConvert.SerializeObject(embeddedJObject);
modified = true;
}
}
}
}
else if (child.HasValues)
{
modified = child.ConvertUtcToLocal(localTimeZone, wasModified) || modified;
}
}
return modified;
}
private static bool ConvertUtcToLocalForArray(JToken token, TimeZoneInfo localTimeZone, bool wasModified, bool modified)
{
foreach (var item in token.Children())
{
var child = item;
if (child.HasValues)
{
modified = child.ConvertUtcToLocal(localTimeZone, wasModified) || modified;
}
}
return modified;
}
private static object ParseJsonValueForDateTime(object value)
{
// If a date/time value includes seconds, it will be cast as a DateTime automatically
// But if it's missing seconds, it will be treated as a string that we'll have to convert to a DateTime
if (value is string)
{
var stringValue = value.ToString();
if (stringValue.FromDateTimeIsoString(out DateTime dateTimeValue))
{
value = dateTimeValue;
}
}
return value;
}
And there you have it. With this framework in place, you can enjoy automatic time zone conversion between local client time zones and UTC built right into your API, complete with proper Daylight Savings Time adjustments when needed.
I hope this helps you cope with time zone concerns in your own applications, so you can focus on the things that really matter, like core business logic.
As always, happy coding!
Leonard Lobel (Microsoft MVP, Data Platform) is a software engineer, architect, and principal consultant at Tallan. He began writing software in 1979, and has been a Microsoft Data Platform MVP for over ten years. He has also authored several books and video courses, and is a frequent speaker at local user groups as well as industry conferences in the U.S. and abroad.
Tallan’s top technologists are always sharing their expertise and thoughts on today’s technology challenges. Click here to learn more!