How to use Cloudfront Functions to rotate content daily at roughly midnight local time.

Content Rotation #

I built two projects for the Yoto Hackathon: Backyard Birds and Fez the Cat. Fez tells a new story each each that contains every letter of the alphabet, except for one letter that listeners need to guess.

Fez’s stories are almost pangrams, the most famous pangram being:

The quick brown fox jumps over the lazy dog

After writing and editing 26 scripts (one for each letter A-Z), I used ElevenLabs to generate audio using a custom voice. Now with 26 MP3 files, I needed to automatically change the story of the day. Yoto allows playlists to include streaming tracks where the device makes an HTTP requests to a static URL each time the user inserts the card.

To rotate content daily I considered:

I choose the second option because it’s cumbersome to explain that, “the story updates every day at Midnight UTC.” I opted instead for a Cloufront Function because they are very low latency and much cheaper than Lambda@Edge. Cloudfront Functions cost a flat $0.10 per 1 million invocations vs. Lambda@Edge which costs approximately $0.60 per 1 million invocations (price depends on region) plus execution time and memory measured in GB-seconds.

There’s nothing wrong with Lambda@Edge, I was just feeling frugal plus What Could Go Wrong?™

Limited Runtime #

Cloudfront Functions has a very limited runtime. The more recent cloudfront-js-2.0 runtime supports ECMAScript 5.1 and some ES6 features. Notably, it lacks networking (no fetch), file system access (no fs), environment variables, or timers.

Important for me, the Date.toLocaleString implementation supported in Cloudfront Functions follows the ECMAScript 5.1 standard where it accepts no arguments. What could have just been a single line with now.toLocaleString("de-DE", { timeZone: 'Europe/Berlin'}) got a lot more complicated.

Ask ChatGPT #

TLDR - Don’t ask AI when there is lot of nuance in a specific runtime environment.

I made the mistake of asking ChatGPT, Claude, and Gemini for help. Each gave me a convincing option. One response suggested I use the non-existent CloudFront-Viewer-Time-Offset header to calculate time in UTC and offset it according to the user’s local time. This would have been great, except a GitHub search yielded exactly 0 results.

Another response indicated that I could use the Date.toLocaleDateString function which, “returns a string with a language-sensitive representation of the date portion of this date in the local timezone.” Spoiler alert: The Cloudfront Functions runtime doesn’t support the Intl API, and “local time” is function start time, always in UTC. This makes sense since all Cloudfront Functions have a strict 1ms execution limit!

Time to Pivot? #

At this point, it would have been much simpler to just migrate over to Lambda@Edge which supports the nodejs22.x runtime, but I was determined to shoehorn a solution using Cloudfront Functions! I could calculate Midnight UTC, and I knew the IANA name of the user’s timezone, so I just needed to map IANA names to UTC offsets to calculate midnight local time.

To map IANA timezones to UTC offsets I considered:

Another important limitation of Cloudfront Functions is that the maximum function size is just 10 KB! Unfortunately, I could not find any NPM packages that would comfortably fit within that limit. Cloudfront KeyValueStore isn’t too expensive at $0.03 per 1 million reads, and probably would have been a convenient long-term solution, especially if I had multiple functions rotating content and reading from the same KV store.

Time Zone Mappings #

I opted to have Gemini create a map from IANA time zone name to UTC offset. To keep the mapping well within the 10 KB limit, I adjusted the mapping structure for the smallest file size. The original mapping was a single map like { "Africa/Kigali": 120 }, but it was well above 10 KB. Smaller was a nested variant representing IANA time zone names like Africa/Kigali mapping to Africa.Kigali with the timezone offset at index 17 in the UTC_OFFSETS_IN_MINUTES array.

const UTC_OFFSETS_IN_MINUTES = [
  -720, -660, -600, -570, -540, -480, -420, -360, -300, -240, -210, -180,
  -150, -120, -60, 0, 60, 120, 180, 210, 240, 270, 300, 330, 345, 360,
  390, 420, 480, 525, 540, 570, 600, 630, 660, 720, 765, 780, 840
];

const TIMEZONES = {
  Africa: {
    Abidjan: 15, Accra: 15, Addis_Ababa: 18, Algiers: 16, Asmara: 18, Asmera: 18,
    Bamako: 15, Bangui: 16, Banjul: 15, Bissau: 15, Blantyre: 17, Brazzaville: 16,
    Bujumbura: 17, Cairo: 17, Casablanca: 16, Ceuta: 16, Conakry: 15, Dakar: 15,
    Dar_es_Salaam: 18, Djibouti: 18, Douala: 16, El_Aaiun: 16, Freetown: 15,
    Gaborone: 17, Harare: 17, Johannesburg: 17, Juba: 17, Kampala: 18, Khartoum: 17,
    Kigali: 17, Kinshasa: 16, Lagos: 16, Libreville: 16, Lome: 15, Luanda: 16,
    Lubumbashi: 17, Lusaka: 17, Malabo: 16, Maputo: 17, Maseru: 17, Mbabane: 17,
    Mogadishu: 18, Monrovia: 15, Nairobi: 18, Ndjamena: 16, Niamey: 16,
    Nouakchott: 15, Ouagadougou: 15, 'Porto-Novo': 16, Sao_Tome: 15, Timbuktu: 15,
    Tripoli: 17, Tunis: 16, Windhoek: 17
  },
  // ...
];

Still fairly clean, this version was 9.98 KB (7.1 KB minified), and I still needed space for my function code. After a few more attempts, I landed on:

const offsets = [
  -720, -660, -600, -570, -540, -480, -420, -360, -300, -240, -210, -180,
  -150, -120, -60, 0, 60, 120, 180, 210, 240, 270, 300, 330, 345, 360,
  390, 420, 480, 525, 540, 570, 600, 630, 660, 720, 765, 780, 840
];

const zones = {
  Africa: {
    15: 'Abidjan Accra Bamako Banjul Bissau Conakry Dakar Freetown Lome Monrovia Nouakchott Ouagadougou Sao_Tome Timbuktu',
    16: 'Algiers Bangui Brazzaville Casablanca Ceuta Douala El_Aaiun Kinshasa Lagos Libreville Luanda Malabo Ndjamena Niamey Porto-Novo Tunis',
    17: 'Blantyre Bujumbura Cairo Gaborone Harare Johannesburg Juba Khartoum Kigali Lubumbashi Lusaka Maputo Maseru Mbabane Tripoli Windhoek',
    18: 'Addis_Ababa Asmara Asmera Dar_es_Salaam Djibouti Kampala Mogadishu Nairobi'
  },
  // ...
];

I estimated that I needed about ~3.5 KB for my function code unminified, and this representation got down to 6.4 KB minified so it fit! The full mapping is available below as a GitHub Gist.

What about Daylight Savings? #

This static timezone mapping has several obvious flaws limitations, most notably:

That said, countries changing timezones isn’t a frequent occurrence. It’s even less likely that I’ll have many users in such countries. Plus, there’s growing evidence that daylight savings is unhealthy.

So this approach doesn’t really rotate content at mightnight local time. Instead, it adjusts at approximately midnight ±1 hour local time, which I’d say is Good Enough™.

Complete Timezone Mapping #

This function is intended to map timezone names to UTC offsets (in minutes), and fit within the constraints of the Cloudfront Function runtime environment without any external dependencies.

const offsets = [
  -720, -660, -600, -570, -540, -480, -420, -360, -300, -240, -210, -180,
  -150, -120, -60, 0, 60, 120, 180, 210, 240, 270, 300, 330, 345, 360,
  390, 420, 480, 525, 540, 570, 600, 630, 660, 720, 765, 780, 840
];

const zones = {
  Africa: {
    15: 'Abidjan Accra Bamako Banjul Bissau Conakry Dakar Freetown Lome Monrovia Nouakchott Ouagadougou Sao_Tome Timbuktu',
    16: 'Algiers Bangui Brazzaville Casablanca Ceuta Douala El_Aaiun Kinshasa Lagos Libreville Luanda Malabo Ndjamena Niamey Porto-Novo Tunis',
    17: 'Blantyre Bujumbura Cairo Gaborone Harare Johannesburg Juba Khartoum Kigali Lubumbashi Lusaka Maputo Maseru Mbabane Tripoli Windhoek',
    18: 'Addis_Ababa Asmara Asmera Dar_es_Salaam Djibouti Kampala Mogadishu Nairobi'
  },
  America: {
    2: 'Adak Atka', 4: 'Anchorage Juneau Metlakatla Nome Sitka Yakutat', 5: 'Ensenada Los_Angeles Santa_Isabel Tijuana Vancouver',
    6: 'Boise Cambridge_Bay Creston Dawson Dawson_Creek Denver Edmonton Fort_Nelson Hermosillo Inuvik Mazatlan Ojinaga Phoenix Shiprock Whitehorse Yellowknife',
    7: 'Bahia_Banderas Belize Chicago Costa_Rica El_Salvador Guatemala Indiana/Knox Indiana/Tell_City Knox_IN Managua Matamoros Menominee Merida Mexico_City Monterrey North_Dakota/Beulah North_Dakota/Center North_Dakota/New_Salem Rainy_River Rankin_Inlet Regina Resolute Swift_Current Tegucigalpa Winnipeg',
    8: 'Atikokan Bogota Cancun Cayman Coral_Harbour Detroit Eirunepe Fort_Wayne Grand_Turk Havana Indiana/Indianapolis Indiana/Marengo Indiana/Petersburg Indiana/Vevay Indiana/Vincennes Indiana/Winamac Indianapolis Iqaluit Jamaica Kentucky/Louisville Kentucky/Monticello Lima Louisville Montreal Nassau New_York Nipigon Panama Pangnirtung Port-au-Prince Porto_Acre Rio_Branco Thunder_Bay Toronto',
    9: 'Anguilla Antigua Aruba Asuncion Barbados Blanc-Sablon Boa_Vista Campo_Grande Caracas Cuiaba Curacao Dominica Glace_Bay Goose_Bay Grenada Guadeloupe Guyana Halifax Kralendijk La_Paz Lower_Princes Manaus Marigot Martinique Moncton Montserrat Port_of_Spain Porto_Velho Puerto_Rico Santiago Santo_Domingo St_Barthelemy St_Kitts St_Lucia St_Thomas St_Vincent Thule Tortola Virgin',
    10: 'St_Johns', 11: 'Araguaina Bahia Belem Buenos_Aires Catamarca Cordoba Cayenne Fortaleza Godthab Jujuy Maceio Mendoza Miquelon Montevideo Nuuk Paramaribo Punta_Arenas Recife Rosario Santarem Sao_Paulo Stanley',
    13: 'Noronha', 14: 'Scoresbysund', 15: 'Danmarkshavn',
    Argentina: { 11: 'Buenos_Aires Catamarca ComodRivadavia Cordoba Jujuy La_Rioja Mendoza Rio_Gallegos Salta San_Juan San_Luis Tucuman Ushuaia' }
  },
  Antarctica: {
    11: 'Palmer Rothera', 15: 'Troll', 18: 'Syowa', 22: 'Mawson', 25: 'Vostok', 27: 'Davis',
    32: 'DumontDUrville Macquarie', 34: 'Casey', 35: 'McMurdo South_Pole GMT+12'
  },
  Asia: {
    17: 'Beirut Famagusta Gaza Hebron Jerusalem Nicosia Tel_Aviv', 18: 'Aden Amman Baghdad Bahrain Damascus Djibouti Istanbul Kuwait Qatar Riyadh',
    19: 'Tehran', 20: 'Baku Dubai Muscat Tbilisi Yerevan', 21: 'Kabul',
    22: 'Aqtau Aqtobe Ashgabat Ashkhabad Atyrau Dushanbe Karachi Oral Qyzylorda Samarkand Tashkent Yekaterinburg',
    23: 'Calcutta Colombo Kolkata', 24: 'Kathmandu Katmandu',
    25: 'Almaty Bishkek Dacca Dhaka Kashgar Omsk Qostanay Thimbu Thimphu Urumqi', 26: 'Rangoon Yangon',
    27: 'Bangkok Barnaul Ho_Chi_Minh Hovd Jakarta Krasnoyarsk Novokuznetsk Novosibirsk Phnom_Penh Pontianak Saigon Tomsk Vientiane',
    28: 'Brunei Choibalsan Chongqing Chungking Hong_Kong Irkutsk Kuala_Lumpur Kuching Macao Macau Makassar Manila Shanghai Singapore Taipei Ulaanbaatar Ulan_Bator',
    30: 'Dili Jayapura Khandyga Pyongyang Seoul Tokyo Yakutsk', 32: 'Chita Ust-Nera Vladivostok',
    34: 'Magadan Sakhalin Srednekolymsk', 35: 'Anadyr Kamchatka'
  },
  Atlantic: { 9: 'Bermuda', 11: 'Stanley', 13: 'South_Georgia', 14: 'Azores Cape_Verde', 15: 'Canary Faeroe Faroe Madeira Reykjavik St_Helena', 16: 'Jan_Mayen' },
  Australia: { 28: 'Perth West', 29: 'Eucla', 31: 'Adelaide Broken_Hill Darwin North South Yancowinna', 32: 'ACT Brisbane Canberra Currie Hobart Lindeman Melbourne NSW Queensland Sydney Tasmania Victoria', 33: 'LHI Lord_Howe' },
  Brazil: { 8: 'Acre', 9: 'West', 11: 'East', 13: 'DeNoronha' },
  Canada: { 5: 'Pacific', 6: 'Mountain Yukon', 7: 'Central Saskatchewan', 8: 'Eastern', 9: 'Atlantic', 10: 'Newfoundland' },
  Chile: { 7: 'EasterIsland', 9: 'Continental' },
  Etc: {
    0: 'GMT+11', 1: 'GMT+10', 6: 'GMT+9', 7: 'GMT+8', 8: 'GMT+7', 9: 'GMT+6', 10: 'GMT+5', 11: 'GMT+4', 12: 'GMT+3', 13: 'GMT+2', 14: 'GMT+1',
    15: 'GMT GMT+0 GMT-0 GMT0 Greenwich UCT UTC Universal Zulu', 16: 'GMT-1', 17: 'GMT-2', 18: 'GMT-3', 20: 'GMT-4', 22: 'GMT-5', 25: 'GMT-6', 27: 'GMT-7', 28: 'GMT-8', 30: 'GMT-9',
    32: 'GMT-10', 34: 'GMT-11', 35: 'GMT-12 GMT+12', 37: 'GMT-13', 38: 'GMT-14'
  },
  Europe: {
    15: 'Belfast Guernsey Isle_of_Man Jersey Lisbon London',
    16: 'Amsterdam Andorra Belgrade Berlin Bratislava Brussels Budapest Busingen Copenhagen Dublin Gibraltar Ljubljana Luxembourg Madrid Malta Monaco Oslo Paris Podgorica Prague Rome San_Marino Sarajevo Skopje Stockholm Tirane Vaduz Vatican Vienna Warsaw Zagreb Zurich',
    17: 'Athens Bucharest Chisinau Famagusta Helsinki Kaliningrad Kiev Mariehamn Nicosia Riga Sofia Tallinn Tiraspol Uzhgorod Vilnius Zaporozhye',
    18: 'Istanbul Kirov Minsk Moscow Simferopol Volgograd', 20: 'Astrakhan Samara Saratov Ulyanovsk'
  },
  Indian: { 18: 'Antananarivo Comoro Mayotte', 20: 'Mahe Mauritius Reunion', 22: 'Kerguelen Maldives', 25: 'Chagos', 26: 'Cocos', 27: 'Christmas' },
  Mexico: { 5: 'BajaNorte', 6: 'BajaSur', 7: 'General' },
  Pacific: {
    '-1': 'GMT+12', 1: 'Midway Niue Pago_Pago Samoa', 2: 'Honolulu Johnston Rarotonga Tahiti', 3: 'Marquesas', 4: 'Gambier', 5: 'Pitcairn', 7: 'Easter Galapagos',
    30: 'Palau', 32: 'Chuuk Guam Port_Moresby Saipan Truk Yap', 34: 'Bougainville Efate Guadalcanal Kosrae Norfolk Noumea Pohnpei Ponape',
    35: 'Auckland Fiji Funafuti Kwajalein Majuro Nauru Tarawa Wake Wallis', 36: 'Chatham', 37: 'Apia Enderbury Fakaofo Kanton Tongatapu', 38: 'Kiritimati'
  },
  US: { 2: 'Aleutian Hawaii', 4: 'Alaska', 5: 'Pacific', 6: 'Arizona Mountain', 7: 'Central Indiana-Starke', 8: 'East-Indiana Eastern Michigan', 1: 'Samoa' },
  2: 'HST', 5: 'PST8PDT', 6: 'MST MST7MDT Navajo', 7: 'CST6CDT', 8: 'Cuba EST EST5EDT Jamaica', 15: 'GB GB-Eire GMT GMT+0 GMT-0 GMT0 Greenwich Iceland Portugal UCT UTC Universal WET Zulu',
  16: 'Arctic/Longyearbyen Atlantic/Jan_Mayen CET Eire MET Poland', 17: 'EET Egypt Israel Libya', 18: 'Asia/Istanbul Europe/Istanbul Turkey W-SU',
  19: 'Iran', 28: 'Hongkong PRC ROC Singapore', 30: 'Japan ROK', 35: 'Kwajalein NZ', 36: 'NZ-CHAT'
};

function getUtcOffset(tz) {
  const parts = tz.split('/');
  const city = parts.pop();
  let node = zones;

  for (let i = 0, e = parts.length; i < e; i++) {
    node = node[parts[i]];
    if (!node) return 0;
  }

  for (const key in node) {
    const val = node[key];
    if (typeof val === 'string' && (' ' + val + ' ').indexOf(' ' + city + ' ') !== -1) {
      return offsets[key];
    }
  }

  return 0;
}