GuidesGOOGLE CALENDAR

Google Calendar ICS feed in JavaScript

Google Calendar's public basic.ics feed sends no Access-Control-Allow-Origin header, so fetch() from a web page fails with TypeError: Failed to fetch even though the link opens fine in a tab. Prefix the feed URL with https://proxy.cors.dev/ and it works: free, no signup, no key, 600 requests per minute and feeds up to 1 MiB. Google's 317-event US holidays feed, 120,685 bytes, came back and parsed in under 300 ms.

Why fetch() fails on basic.ics

We called a Google Calendar .ics link from a Chrome 153 page on https://example.com on 26 October 2026. fetch() threw TypeError: Failed to fetch and the console said: "Access to fetch at '...basic.ics' from origin 'https://example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource." The feed opens in a tab because navigation does not check CORS; JavaScript reads do. Access to fetch blocked by CORS policy explains that message in general. Google's JSON Calendar API does send CORS headers, but it needs an API key, while the .ics link needs no Google Cloud project at all.

Find the public ICS address

Turn on public sharing for the calendar, then copy its iCal address.

  1. Make the calendar public

    in Google Calendar settings, click the calendar under "Settings for my calendars", and under "Access permissions for events" check "Make available to public". "See only free/busy (hide details)" is offered there if you want to hide event details.

  2. Open Integrate calendar

    it is further down the same settings page.

  3. Copy the public iCal address

    the URL under "Public address in iCal format". Google says it only works while the calendar is public. Turning sharing off usually takes a few minutes, but can take up to 4 hours.

The address looks like https://calendar.google.com/calendar/ical/CALENDAR_ID/public/basic.ics, with the calendar ID URL-encoded: # becomes %23 and @ becomes %40. Google's US holidays calendar, for example, has the ID en.usa#holiday@group.v.calendar.google.com. If you were given a webcal:// link, change it to https:// first, because fetch() rejects the webcal scheme with "URL scheme "webcal" is not supported".

Fetch and parse the feed

Put https://proxy.cors.dev/ in front of the https:// feed URL and pass credentials: 'omit'. When the proxy refuses a response, for example a feed over the size cap, it says why in the X-Cors-Error header. To parse the text, load ical.js 2.2.1 as an ES module from jsDelivr, run ICAL.parse, wrap the result in new ICAL.Component(...), take the events with getAllSubcomponents('vevent') and wrap each in new ICAL.Event(...).

Next 10 events from a public Google Calendar, recurring events included
// Runs in a module script or the DevTools console
const { default: ICAL } = await import('https://cdn.jsdelivr.net/npm/ical.js@2.2.1/dist/ical.js');

const calendarId = 'en.usa#holiday@group.v.calendar.google.com';
const feed = `https://calendar.google.com/calendar/ical/${encodeURIComponent(calendarId)}/public/basic.ics`;

const response = await fetch('https://proxy.cors.dev/' + feed, { credentials: 'omit' });
if (!response.ok) throw new Error(response.headers.get('X-Cors-Error') ?? `HTTP ${response.status}`);

const calendar = new ICAL.Component(ICAL.parse(await response.text()));
const vevents = calendar.getAllSubcomponents('vevent');
const events = vevents.filter((vevent) => !vevent.hasProperty('recurrence-id')).map((vevent) => new ICAL.Event(vevent));
const byUid = new Map(events.map((event) => [event.uid, event]));

// Moved or edited occurrences arrive as separate VEVENTs that share the UID
for (const vevent of vevents) {
  if (vevent.hasProperty('recurrence-id')) byUid.get(vevent.getFirstPropertyValue('uid'))?.relateException(vevent);
}

const from = ICAL.Time.fromJSDate(new Date(), true);
const until = ICAL.Time.fromJSDate(new Date(Date.now() + 90 * 86_400_000), true);
const upcoming = [];

for (const event of events) {
  const iterator = event.iterator();
  for (let next = iterator.next(); next && next.compare(until) < 0; next = iterator.next()) {
    const { item, startDate } = event.getOccurrenceDetails(next);
    if (startDate.compare(from) >= 0) upcoming.push({ start: startDate.toJSDate(), allDay: startDate.isDate, title: item.summary });
    if (!event.isRecurring()) break;
  }
}

upcoming.sort((a, b) => a.start - b.start);
console.table(upcoming.slice(0, 10));

On the US holidays calendar the proxy returned 200, text/calendar, 120,685 bytes: 317 events from 2021-01-01 to 2031-12-31, about 380 bytes each. The snippet printed the next 10 holidays, starting with Halloween, Daylight Saving Time ends, Election Day and Veterans Day.

Recurring events, time zones and all-day events

  • Recurring events: a series is one VEVENT with an RRULE, not one entry per date, and event.iterator() yields each occurrence. The US holidays feed has no RRULE at all. The HoloViz community calendar has 42 recurring series, and the snippet expanded 6 of its meetings in the next 90 days, every 2 weeks at 15:00 UTC.
  • Moved occurrences: an edited or moved meeting arrives as an extra VEVENT with the same UID and a RECURRENCE-ID. In the HoloViz feed that is 193 of its 255 VEVENT blocks, and 14 of them moved a meeting to another time, such as 21 May 2024 from 08:30 to 09:00. The snippet attaches them with relateException and reads each occurrence through getOccurrenceDetails, so a moved meeting shows once, at its new time.
  • Time zones: events carry a TZID and the feed includes the matching VTIMEZONE blocks. ical.js 2.2.1 resolved them without manual registration: 17:00 Europe/Berlin on 16 January 2023 came out as 16:00 UTC.
  • All-day events: these are DATE values. startDate.isDate is true and toJSDate() returns local midnight, so show the date without a time.

Calendar API instead of ICS

Google Calendar API v3 is the keyed alternative: https://www.googleapis.com/calendar/v3/calendars/ID/events?key=KEY returns JSON and sends CORS headers. A call with a bad key came back as a readable 400, "API key not valid. Please pass a valid API key." With singleEvents=true and orderBy=startTime, Google expands recurring events for you. An API key alone only reads public calendars, and it sits in your page source, so restrict it to your site and to the Calendar API. Google's limits, updated September 2026: 10,000 requests per minute per project and 600 per minute per user, and projects created on or after May 1, 2026 get a 1,000,000 requests per day threshold before charges apply. Standard use is free.

The same calendar through the Calendar API, with a browser key
// The key ends up in your page source: restrict it to your site and to the Calendar API
const apiKey = 'YOUR_BROWSER_KEY';
const calendarId = 'en.usa#holiday@group.v.calendar.google.com';

const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`);
url.search = new URLSearchParams({
  key: apiKey,
  timeMin: new Date().toISOString(),
  singleEvents: 'true',
  orderBy: 'startTime',
  maxResults: '10',
});

const response = await fetch(url);
const body = await response.json();
if (!response.ok) throw new Error(body.error?.message ?? `HTTP ${response.status}`);

console.table(body.items.map((event) => ({ start: event.start.dateTime ?? event.start.date, title: event.summary })));

Options and limits

Ways to show Google Calendar events on a web page, checked October 2026
OptionKey neededfetch() from a pageLimitsReturns
Public ICS feed, directNoBlocked, no Access-Control-Allow-Origin headerUnusable from page JavaScripttext/calendar
Public ICS feed via proxy.cors.devNoWorks600 requests per minute per IP, 1 MiB per response, 10 s deadlinetext/calendar, 120,685 bytes for 317 US holidays
Calendar API v3 events.listBrowser API keyWorks10,000 requests per minute per project, 600 per userJSON, recurring events expanded with singleEvents=true
Google's embed iframeNoNot neededGoogle's layout and styling onlyA calendar page inside an iframe
Secret address in iCal formatNoBlocked, and never safe in a pageAnyone with the URL reads every eventThe full private calendar
  • Size: the free proxy relays text responses, calendar feeds included, up to 1 MiB. The Linux Foundation's public calendar is 1,160,668 bytes with 777 events, and the free tier answered 502 with X-Cors-Error: response_too_large, plus an X-Cors-Signup header and an upgrade link in the JSON body. Pro raises the cap to 6 MiB.
  • Events per MiB: at the holidays feed's 380 bytes per event, about 2,750 events fit in 1 MiB. Events with descriptions take more.
  • Caching: Google sends Cache-Control: no-cache, no-store, max-age=0, must-revalidate, an Expires date in 1990 and a cookie, so nothing caches the feed. Pro's optional 1 to 300 second GET cache respects those headers and skips it too. Keep the parsed events in memory if you render them more than once.
  • Rate and time: 600 requests per minute per IP on the free tier, no monthly quota, and a 10 second deadline per request. Pro is $5 a month for 500,000 requests, 600 per minute and 10 at a time; see pricing and limits.

Never put the secret address in a page

Google Calendar also shows a "Secret address in iCal format". It gives read access to every event on the calendar; Google says only you should know it and offers a Reset button for when it leaks. Anything in front-end code, a proxied URL included, is visible to every visitor in page source and the network tab. Use the public address in browser code, or fetch the secret one from your own server.

Good questions.

Why does fetch() fail on a Google Calendar .ics link?

Google serves basic.ics without an Access-Control-Allow-Origin header, so the browser blocks JavaScript from reading it and fetch() throws TypeError: Failed to fetch. The link still opens in a tab because navigation is not subject to CORS.

How do I fetch a webcal:// link in JavaScript?

Replace webcal:// with https://, then fetch it through the proxy. fetch() does not support the webcal scheme.

Is it safe to send the secret iCal address through the proxy?

No. Any URL your page requests is visible to visitors, and the secret address gives read access to every event. Fetch it from your own server instead.

How many events fit through the free proxy?

About 2,750 short events fit in the 1 MiB limit, going by the 317-event holidays feed at 120,685 bytes. The 777-event Linux Foundation calendar is 1,160,668 bytes and needs Pro's 6 MiB.

Do I need a Google API key to read a public ICS feed?

No. The public basic.ics link needs no key or Google Cloud project, as long as the calendar is public. You only need a key for the Calendar API.

Does cors.dev cache the Google Calendar feed?

No. Google marks the feed no-store, no-cache and already expired, and the proxy respects that, Pro caching included. Cache the parsed events in your own code if you need fewer requests.